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)
-
+