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:
parent
47151ebe8c
commit
7a67c2182f
8 changed files with 294 additions and 24 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue