diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 27508b0..72f3480 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -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 { 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 { 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 { } }) + // 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 { 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 => { console.log('[HAL] Dispensing:', amounts) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 9107951..fc48d87 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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', () => { diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index 1e20cc3..f48fda8 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -109,6 +109,12 @@ export async function initializeHalServices(config: HalConfig): Promise = {} for (const cassette of dispConfig.cassettes) { @@ -206,10 +212,13 @@ export async function initializeHalServices(config: HalConfig): Promise { + 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 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((resolve) => { diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 2bfaeea..a770dae 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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 }) } diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index 16ee6be..5210c86 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -242,7 +242,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)

-

+

+ ⏳ Processing bill… +

+

Maximum amount reached — press Done to continue

@@ -269,11 +272,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice) - +