fix(machine): credit bills on stacked-confirmation, not stack command (#58)

Backports the legacy brain.js escrow interlock (from the public-domain
lamassu-machine tree at c0b69d1, see CLAUDE.md provenance):

- The id003/ebds drivers' `billsValid` event (bill physically reached
  the stacker) is now the credit trigger. hal-service tracks
  escrow → in-flight and fires onBillInserted only on confirmation;
  hal:stack-bill no longer synthesizes the credit at command time.
- New BILL_PENDING machine event marks the in-flight bill;
  FINISH_INSERTING is guard-blocked while one is pending, so "done"
  pressed mid-stack can no longer mint an LNURL that includes a bill
  still sitting in escrow (the aiolabs/bitspire#58 loss).
- BILL_INSERTED now requires a matching pending bill (stray or
  out-of-state confirmations are never credited) and BILL_REJECTED
  clears the in-flight marker — a failed/returned stack was never
  credited, so nothing to unwind.
- Escrow decision is fail-closed (legacy _billsRead parity): bill read
  outside insertingBills, or with unknown rate/balance, is returned to
  the customer instead of stacked-and-swallowed (closes the #35 gap at
  the decision point that physically takes the money).
- CashInView disables "Done" and shows a processing hint while a bill
  is in flight; the dev simulator drives the same guarded two-event
  path.

Both loss directions verified against the legacy semantics:
operator-pays-for-unstacked-cash and customer-bill-swallowed-uncredited.

6 new state-machine interlock tests; 27 state-machine + 43 machine-app
tests pass; full build (vue-tsc + vite + electron tsc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-04 01:07:58 +02:00
commit 7a67c2182f
8 changed files with 294 additions and 24 deletions

View file

@ -36,6 +36,11 @@ export interface HalConfig {
export interface ValidatorCallbacks {
shouldAcceptBill: (denomination: number) => boolean | 'hold'
onBillRead?: (denomination: number) => void
/**
* Fires on the validator's stacked-confirmation (`billsValid`) — the
* bill physically reached the stacker. This is the CREDIT event; it is
* NOT emitted at stack-command time (a stack can still fail/return).
*/
onBillInserted: (denomination: number) => void
onBillRejected: (reason: string) => void
onError: (error: string) => void
@ -142,6 +147,14 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0,
}))
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
// decision; `inFlightDenomination` = stack commanded, awaiting the
// validator's `billsValid` stacked-confirmation. onBillInserted (the
// credit event) fires only on that confirmation.
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
return {
connectValidator: (callbacks: ValidatorCallbacks) => {
if (!validator) {
@ -153,10 +166,11 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
const decision = callbacks.shouldAcceptBill(data.denomination)
if (decision === 'hold') {
console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination)
} else if (decision) {
inFlightDenomination = data.denomination
validator.stack()
callbacks.onBillInserted(data.denomination)
} else {
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
validator.reject()
@ -168,7 +182,23 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
}
})
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
// Covers both an escrow refusal and a failed/returned stack —
// either way nothing was credited and nothing is in flight.
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown')
})
@ -195,8 +225,19 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
validator?.lightOff()
},
stackBill: () => validator?.stack(),
rejectBill: () => validator?.reject(),
stackBill: () => {
if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator?.stack()
},
rejectBill: () => {
escrowDenomination = null
validator?.reject()
},
dispenseCash: async (amounts): Promise<DispenseResult> => {
console.log('[HAL] Dispensing:', amounts)

View file

@ -585,10 +585,12 @@ ipcMain.handle('hal:stack-bill', () => {
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
return
}
const denomination = pendingBillDenomination
pendingBillDenomination = null
// Credit is NOT sent here. hal-service fires onBillInserted (forwarded
// as 'hal:bill-inserted') only on the validator's `billsValid`
// stacked-confirmation — a stack command can still fail or return the
// bill (aiolabs/bitspire#58).
halInstance.stackBill()
mainWindow?.webContents.send('hal:bill-inserted', denomination)
})
ipcMain.handle('hal:reject-bill', () => {

View file

@ -109,6 +109,12 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
})
console.log('[HAL] Validator started')
// Escrow / in-flight bookkeeping: credit (onBillInserted) fires only on
// the validator's `billsValid` stacked-confirmation, never at
// stack-command time (mirrors electron/hal-service.ts).
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
// Track inventory (decremented on dispense)
const inventory: Record<number, number> = {}
for (const cassette of dispConfig.cassettes) {
@ -206,10 +212,13 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
if (decision === 'hold') {
// Hold in escrow — caller will call stackBill() or rejectBill()
console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination)
} else if (decision) {
// Credit waits for the validator's stacked-confirmation
// (`billsValid`) — see the handler below.
inFlightDenomination = data.denomination
validator.stack()
callbacks.onBillInserted(data.denomination)
} else {
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
validator.reject()
@ -221,7 +230,21 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
}
})
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown')
})
@ -248,8 +271,19 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
validator.lightOff()
},
stackBill: () => validator.stack(),
rejectBill: () => validator.reject(),
stackBill: () => {
if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator.stack()
},
rejectBill: () => {
escrowDenomination = null
validator.reject()
},
cleanup: async () => {
return new Promise<void>((resolve) => {

View file

@ -949,11 +949,20 @@ export const useAtmStore = defineStore('atm', () => {
// Wire validator events to state machine
hal.connectValidator({
shouldAcceptBill: (denomination) => {
// Check if accepting this bill would exceed available balance
const ctx = context.value
if (!ctx || ctx.exchangeRate === 0) {
console.warn('[ATM] Cannot check balance: no exchange rate')
return true // Allow if we don't have rate yet (shouldn't happen)
// Fail closed: no rate/balance, or not in the accepting state →
// return the bill (legacy _billsRead parity).
if (
nestedState.value !== 'insertingBills' ||
!ctx ||
ctx.exchangeRate <= 0 ||
ctx.availableBalance <= 0
) {
console.log(
`[ATM] Rejecting $${denomination} bill: not accepting (state/rate/balance unknown)`
)
return false
}
// Calculate what the new sats amount would be
@ -971,6 +980,9 @@ export const useAtmStore = defineStore('atm', () => {
return false
}
// Accepting: mark the bill in flight. The HAL service issues the
// stack command; BILL_INSERTED follows on stacked-confirmation.
send({ type: 'BILL_PENDING', denomination })
return true
},
onBillInserted: (denomination) => {
@ -1249,11 +1261,22 @@ export const useAtmStore = defineStore('atm', () => {
// Wire validator events from main process via IPC
api.onHalBillRead((denomination) => {
console.log('[ATM] Bill in escrow:', denomination)
// Check if we should accept this bill
const ctx = context.value
if (!ctx || ctx.exchangeRate === 0) {
// No rate yet, accept anyway
api.halStackBill()
// Fail closed (legacy _billsRead parity): only stack while the
// machine is accepting bills AND rate + balance are known.
// Anything else returns the bill to the customer — stacking here
// would swallow cash the machine can't (or won't) credit.
if (
nestedState.value !== 'insertingBills' ||
!ctx ||
ctx.exchangeRate <= 0 ||
ctx.availableBalance <= 0
) {
console.log(
`[ATM] Rejecting $${denomination} bill: not accepting (state=${nestedState.value}, rate=${ctx?.exchangeRate ?? 'n/a'}, balance=${ctx?.availableBalance ?? 'n/a'})`
)
api.halRejectBill()
return
}
@ -1272,7 +1295,10 @@ export const useAtmStore = defineStore('atm', () => {
return
}
// Accept the bill
// Accept the bill: mark it in flight, then command the stack.
// Credit (BILL_INSERTED) arrives via onHalBillInserted once the
// validator confirms the bill reached the stacker.
send({ type: 'BILL_PENDING', denomination })
api.halStackBill()
})
@ -1366,6 +1392,11 @@ export const useAtmStore = defineStore('atm', () => {
}
function insertBill(denomination: number) {
// Dev simulator: a real validator goes escrow → stack command →
// stacked-confirmation. Emit both halves so the simulated bill runs
// the same guarded path (BILL_PENDING is balance-gated; a refused
// pending drops the credit too).
send({ type: 'BILL_PENDING', denomination })
send({ type: 'BILL_INSERTED', denomination })
}

View file

@ -242,7 +242,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</p>
<!-- Status -->
<p v-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
<p v-if="context?.billPending" class="text-sm lg:text-xl text-muted-foreground">
⏳ Processing bill…
</p>
<p v-else-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
Maximum amount reached — press Done to continue
</p>
<p v-else class="text-sm lg:text-xl text-muted-foreground">
@ -269,11 +272,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</AlertDescription>
</Alert>
<!-- Done button -->
<!-- Done button — also blocked while a bill is between the
stack command and the validator's stacked-confirmation
(the machine guard drops FINISH_INSERTING regardless;
this keeps the UI honest about it) -->
<Button
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
size="kiosk-lg"
:disabled="!context || context.billsInserted.length === 0"
:disabled="!context || context.billsInserted.length === 0 || context.billPending !== null"
@click="finishInserting"
>
Done Inserting