fix(machine): credit bills on stacked-confirmation, not stack command (#58) #77
8 changed files with 294 additions and 24 deletions
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>
commit
7a67c2182f
|
|
@ -36,6 +36,11 @@ export interface HalConfig {
|
||||||
export interface ValidatorCallbacks {
|
export interface ValidatorCallbacks {
|
||||||
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
||||||
onBillRead?: (denomination: number) => void
|
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
|
onBillInserted: (denomination: number) => void
|
||||||
onBillRejected: (reason: string) => void
|
onBillRejected: (reason: string) => void
|
||||||
onError: (error: string) => void
|
onError: (error: string) => void
|
||||||
|
|
@ -142,6 +147,14 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
count: c.count ?? 0,
|
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 {
|
return {
|
||||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||||
if (!validator) {
|
if (!validator) {
|
||||||
|
|
@ -153,10 +166,11 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
const decision = callbacks.shouldAcceptBill(data.denomination)
|
const decision = callbacks.shouldAcceptBill(data.denomination)
|
||||||
if (decision === 'hold') {
|
if (decision === 'hold') {
|
||||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||||
|
escrowDenomination = data.denomination
|
||||||
callbacks.onBillRead?.(data.denomination)
|
callbacks.onBillRead?.(data.denomination)
|
||||||
} else if (decision) {
|
} else if (decision) {
|
||||||
|
inFlightDenomination = data.denomination
|
||||||
validator.stack()
|
validator.stack()
|
||||||
callbacks.onBillInserted(data.denomination)
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
||||||
validator.reject()
|
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 }) => {
|
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')
|
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -195,8 +225,19 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
validator?.lightOff()
|
validator?.lightOff()
|
||||||
},
|
},
|
||||||
|
|
||||||
stackBill: () => validator?.stack(),
|
stackBill: () => {
|
||||||
rejectBill: () => validator?.reject(),
|
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> => {
|
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
||||||
console.log('[HAL] Dispensing:', amounts)
|
console.log('[HAL] Dispensing:', amounts)
|
||||||
|
|
|
||||||
|
|
@ -585,10 +585,12 @@ ipcMain.handle('hal:stack-bill', () => {
|
||||||
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
|
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const denomination = pendingBillDenomination
|
|
||||||
pendingBillDenomination = null
|
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()
|
halInstance.stackBill()
|
||||||
mainWindow?.webContents.send('hal:bill-inserted', denomination)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('hal:reject-bill', () => {
|
ipcMain.handle('hal:reject-bill', () => {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
})
|
})
|
||||||
console.log('[HAL] Validator started')
|
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)
|
// Track inventory (decremented on dispense)
|
||||||
const inventory: Record<number, number> = {}
|
const inventory: Record<number, number> = {}
|
||||||
for (const cassette of dispConfig.cassettes) {
|
for (const cassette of dispConfig.cassettes) {
|
||||||
|
|
@ -206,10 +212,13 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
if (decision === 'hold') {
|
if (decision === 'hold') {
|
||||||
// Hold in escrow — caller will call stackBill() or rejectBill()
|
// Hold in escrow — caller will call stackBill() or rejectBill()
|
||||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||||
|
escrowDenomination = data.denomination
|
||||||
callbacks.onBillRead?.(data.denomination)
|
callbacks.onBillRead?.(data.denomination)
|
||||||
} else if (decision) {
|
} else if (decision) {
|
||||||
|
// Credit waits for the validator's stacked-confirmation
|
||||||
|
// (`billsValid`) — see the handler below.
|
||||||
|
inFlightDenomination = data.denomination
|
||||||
validator.stack()
|
validator.stack()
|
||||||
callbacks.onBillInserted(data.denomination)
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
|
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
|
||||||
validator.reject()
|
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 }) => {
|
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||||
|
escrowDenomination = null
|
||||||
|
inFlightDenomination = null
|
||||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -248,8 +271,19 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
validator.lightOff()
|
validator.lightOff()
|
||||||
},
|
},
|
||||||
|
|
||||||
stackBill: () => validator.stack(),
|
stackBill: () => {
|
||||||
rejectBill: () => validator.reject(),
|
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 () => {
|
cleanup: async () => {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
|
|
|
||||||
|
|
@ -949,11 +949,20 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Wire validator events to state machine
|
// Wire validator events to state machine
|
||||||
hal.connectValidator({
|
hal.connectValidator({
|
||||||
shouldAcceptBill: (denomination) => {
|
shouldAcceptBill: (denomination) => {
|
||||||
// Check if accepting this bill would exceed available balance
|
|
||||||
const ctx = context.value
|
const ctx = context.value
|
||||||
if (!ctx || ctx.exchangeRate === 0) {
|
|
||||||
console.warn('[ATM] Cannot check balance: no exchange rate')
|
// Fail closed: no rate/balance, or not in the accepting state →
|
||||||
return true // Allow if we don't have rate yet (shouldn't happen)
|
// 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
|
// Calculate what the new sats amount would be
|
||||||
|
|
@ -971,6 +980,9 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
return false
|
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
|
return true
|
||||||
},
|
},
|
||||||
onBillInserted: (denomination) => {
|
onBillInserted: (denomination) => {
|
||||||
|
|
@ -1249,11 +1261,22 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Wire validator events from main process via IPC
|
// Wire validator events from main process via IPC
|
||||||
api.onHalBillRead((denomination) => {
|
api.onHalBillRead((denomination) => {
|
||||||
console.log('[ATM] Bill in escrow:', denomination)
|
console.log('[ATM] Bill in escrow:', denomination)
|
||||||
// Check if we should accept this bill
|
|
||||||
const ctx = context.value
|
const ctx = context.value
|
||||||
if (!ctx || ctx.exchangeRate === 0) {
|
|
||||||
// No rate yet, accept anyway
|
// Fail closed (legacy _billsRead parity): only stack while the
|
||||||
api.halStackBill()
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1272,7 +1295,10 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
return
|
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()
|
api.halStackBill()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1366,6 +1392,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertBill(denomination: number) {
|
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 })
|
send({ type: 'BILL_INSERTED', denomination })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- 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
|
Maximum amount reached — press Done to continue
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm lg:text-xl text-muted-foreground">
|
<p v-else class="text-sm lg:text-xl text-muted-foreground">
|
||||||
|
|
@ -269,11 +272,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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
|
<Button
|
||||||
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
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"
|
size="kiosk-lg"
|
||||||
:disabled="!context || context.billsInserted.length === 0"
|
:disabled="!context || context.billsInserted.length === 0 || context.billPending !== null"
|
||||||
@click="finishInserting"
|
@click="finishInserting"
|
||||||
>
|
>
|
||||||
Done Inserting
|
Done Inserting
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,17 @@ describe('ATM State Machine', () => {
|
||||||
// Wait for rate fetch
|
// Wait for rate fetch
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
// Real hardware flow: escrow accepted (stack commanded) →
|
||||||
|
// stacked-confirmation credits the bill.
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 10 })
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
|
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
|
||||||
|
|
||||||
const context = actor.getSnapshot().context
|
const context = actor.getSnapshot().context
|
||||||
expect(context.billsInserted).toEqual([20, 10])
|
expect(context.billsInserted).toEqual([20, 10])
|
||||||
expect(context.fiatCents).toBe(3000) // $30 in cents
|
expect(context.fiatCents).toBe(3000) // $30 in cents
|
||||||
|
expect(context.billPending).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return to idle on CANCEL', async () => {
|
it('should return to idle on CANCEL', async () => {
|
||||||
|
|
@ -152,6 +157,112 @@ describe('ATM State Machine', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('cash-in escrow credit interlock (aiolabs/bitspire#58)', () => {
|
||||||
|
// Legacy brain.js parity: a bill is credited only on the validator's
|
||||||
|
// stacked-confirmation, and the insert phase cannot finish while a
|
||||||
|
// bill is between the stack command and that confirmation.
|
||||||
|
|
||||||
|
async function startInserting() {
|
||||||
|
const machine = createATMMachine(mockServices)
|
||||||
|
const actor = createActor(machine)
|
||||||
|
actor.start()
|
||||||
|
actor.send({ type: 'SELECT_CASH_IN' })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
expect(actor.getSnapshot().value).toEqual({ cashIn: 'insertingBills' })
|
||||||
|
return actor
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks FINISH_INSERTING while a bill is in flight', async () => {
|
||||||
|
const actor = await startInserting()
|
||||||
|
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
||||||
|
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||||
|
// Second bill enters flight; user mashes "done" before it settles.
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 1 })
|
||||||
|
actor.send({ type: 'FINISH_INSERTING' })
|
||||||
|
|
||||||
|
// Still inserting — the in-flight bill holds the door.
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.value).toEqual({ cashIn: 'insertingBills' })
|
||||||
|
expect(snap.context.billPending).toBe(1)
|
||||||
|
// Nothing was credited for the in-flight bill.
|
||||||
|
expect(snap.context.fiatCents).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows FINISH_INSERTING once the in-flight bill is confirmed', async () => {
|
||||||
|
const actor = await startInserting()
|
||||||
|
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 1 })
|
||||||
|
actor.send({ type: 'FINISH_INSERTING' }) // blocked
|
||||||
|
actor.send({ type: 'BILL_INSERTED', denomination: 1 }) // confirmation lands
|
||||||
|
actor.send({ type: 'FINISH_INSERTING' })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.value).not.toEqual({ cashIn: 'insertingBills' })
|
||||||
|
expect(snap.context.fiatCents).toBe(100)
|
||||||
|
expect(snap.context.billPending).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('BILL_REJECTED clears the in-flight bill without crediting', async () => {
|
||||||
|
const actor = await startInserting()
|
||||||
|
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
||||||
|
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 10 })
|
||||||
|
// Stack failed — validator returned the bill.
|
||||||
|
actor.send({ type: 'BILL_REJECTED', reason: 'returned' })
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.context.billPending).toBeNull()
|
||||||
|
expect(snap.context.fiatCents).toBe(2000) // only the confirmed bill
|
||||||
|
expect(snap.context.billsInserted).toEqual([20])
|
||||||
|
|
||||||
|
// And the door is open again.
|
||||||
|
actor.send({ type: 'FINISH_INSERTING' })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
expect(actor.getSnapshot().value).not.toEqual({ cashIn: 'insertingBills' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not credit a BILL_INSERTED with no matching pending bill', async () => {
|
||||||
|
const actor = await startInserting()
|
||||||
|
|
||||||
|
// Stray stacked-confirmation (no stack was commanded).
|
||||||
|
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.context.fiatCents).toBe(0)
|
||||||
|
expect(snap.context.billsInserted).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the credit too when BILL_PENDING was refused (over balance)', async () => {
|
||||||
|
const actor = await startInserting()
|
||||||
|
|
||||||
|
// Mock balance is 1M sats @ 2500 sats/USD → $400 ceiling. $500 bill
|
||||||
|
// exceeds it: pending is guard-refused, so its confirmation (which a
|
||||||
|
// correctly-driven validator would never send) is not credited either.
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 500 })
|
||||||
|
actor.send({ type: 'BILL_INSERTED', denomination: 500 })
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.context.billPending).toBeNull()
|
||||||
|
expect(snap.context.fiatCents).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CANCEL with a bill in flight lands in confirmAbandon with pending cleared', async () => {
|
||||||
|
const actor = await startInserting()
|
||||||
|
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
||||||
|
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||||
|
actor.send({ type: 'BILL_PENDING', denomination: 10 })
|
||||||
|
actor.send({ type: 'CANCEL' })
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.value).toEqual({ cashIn: 'confirmAbandon' })
|
||||||
|
expect(snap.context.billPending).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('cash-out flow (ATM-driven amount selection)', () => {
|
describe('cash-out flow (ATM-driven amount selection)', () => {
|
||||||
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
|
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
|
||||||
const machine = createATMMachine(mockServices)
|
const machine = createATMMachine(mockServices)
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,15 @@ export function createATMMachine(
|
||||||
setCashOutFee: assign({
|
setCashOutFee: assign({
|
||||||
feeFraction: ({ context }) => context.cashOutFeeFraction,
|
feeFraction: ({ context }) => context.cashOutFeeFraction,
|
||||||
}),
|
}),
|
||||||
|
setBillPending: assign({
|
||||||
|
billPending: ({ context, event }) => {
|
||||||
|
if (event.type !== 'BILL_PENDING') return context.billPending
|
||||||
|
return event.denomination
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
clearBillPending: assign({
|
||||||
|
billPending: null,
|
||||||
|
}),
|
||||||
addBill: assign({
|
addBill: assign({
|
||||||
billsInserted: ({ context, event }) => {
|
billsInserted: ({ context, event }) => {
|
||||||
if (event.type !== 'BILL_INSERTED') return context.billsInserted
|
if (event.type !== 'BILL_INSERTED') return context.billsInserted
|
||||||
|
|
@ -190,6 +199,7 @@ export function createATMMachine(
|
||||||
if (event.type !== 'BILL_INSERTED') return context.fiatCents
|
if (event.type !== 'BILL_INSERTED') return context.fiatCents
|
||||||
return context.fiatCents + event.denomination * 100
|
return context.fiatCents + event.denomination * 100
|
||||||
},
|
},
|
||||||
|
billPending: null,
|
||||||
}),
|
}),
|
||||||
calculateSats: assign({
|
calculateSats: assign({
|
||||||
satsAmount: ({ context }) => {
|
satsAmount: ({ context }) => {
|
||||||
|
|
@ -367,13 +377,26 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
|
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
|
||||||
|
// Legacy brain.js parity: "send coins" is a no-op while a bill is
|
||||||
|
// between the stack command and the validator's stacked-confirmation.
|
||||||
|
canFinishInserting: ({ context }) =>
|
||||||
|
context.billsInserted.length > 0 && context.billPending === null,
|
||||||
|
// A credit event must correspond to the bill we commanded to stack —
|
||||||
|
// a stray stacked-confirmation with no pending bill is not credited.
|
||||||
|
billMatchesPending: ({ context, event }) => {
|
||||||
|
if (event.type !== 'BILL_INSERTED') return false
|
||||||
|
return context.billPending === event.denomination
|
||||||
|
},
|
||||||
hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum
|
hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum
|
||||||
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
|
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
|
||||||
canRetry: ({ context }) => context.retryCount < 3,
|
canRetry: ({ context }) => context.retryCount < 3,
|
||||||
hasUserNpub: ({ context }) => context.userNpub !== null,
|
hasUserNpub: ({ context }) => context.userNpub !== null,
|
||||||
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
|
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
|
||||||
|
// Gate at the PENDING (pre-stack) decision, matching legacy
|
||||||
|
// _billsRead: balance/rate checks happen before the bill is
|
||||||
|
// physically committed. Once stacked, credit is unconditional.
|
||||||
billWithinBalance: ({ context, event }) => {
|
billWithinBalance: ({ context, event }) => {
|
||||||
if (event.type !== 'BILL_INSERTED') return false
|
if (event.type !== 'BILL_PENDING') return false
|
||||||
// Reject bills if balance or rate is unknown — don't risk accepting
|
// Reject bills if balance or rate is unknown — don't risk accepting
|
||||||
// cash we can't cover with sats
|
// cash we can't cover with sats
|
||||||
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
|
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
|
||||||
|
|
@ -476,15 +499,25 @@ export function createATMMachine(
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
BILL_INSERTED: {
|
// Escrow accepted → stack commanded. Marks the bill in
|
||||||
|
// flight; credit waits for the stacked-confirmation.
|
||||||
|
BILL_PENDING: {
|
||||||
guard: 'billWithinBalance',
|
guard: 'billWithinBalance',
|
||||||
|
actions: 'setBillPending',
|
||||||
|
},
|
||||||
|
// Validator confirmed the bill reached the stacker. The
|
||||||
|
// cash is physically in the box — credit unconditionally.
|
||||||
|
BILL_INSERTED: {
|
||||||
|
guard: 'billMatchesPending',
|
||||||
actions: ['addBill', 'calculateSats'],
|
actions: ['addBill', 'calculateSats'],
|
||||||
},
|
},
|
||||||
BILL_REJECTED: {
|
BILL_REJECTED: {
|
||||||
// Stay in state, maybe show message
|
// Bill returned to customer (stack failed or refused) —
|
||||||
|
// it was never credited; just clear the in-flight marker.
|
||||||
|
actions: 'clearBillPending',
|
||||||
},
|
},
|
||||||
FINISH_INSERTING: {
|
FINISH_INSERTING: {
|
||||||
guard: 'hasInsertedBills',
|
guard: 'canFinishInserting',
|
||||||
target: 'generatingNdebit',
|
target: 'generatingNdebit',
|
||||||
},
|
},
|
||||||
CANCEL: [
|
CANCEL: [
|
||||||
|
|
@ -549,6 +582,9 @@ export function createATMMachine(
|
||||||
confirmAbandon: {
|
confirmAbandon: {
|
||||||
// Warning: cash is in the machine, abandoning forfeits it.
|
// Warning: cash is in the machine, abandoning forfeits it.
|
||||||
// Auto-clear after 60s so the machine self-recovers if customer walked away.
|
// Auto-clear after 60s so the machine self-recovers if customer walked away.
|
||||||
|
// A bill still in flight when the user bails is forfeited
|
||||||
|
// like the rest — clear the marker so nothing blocks on it.
|
||||||
|
entry: 'clearBillPending',
|
||||||
after: {
|
after: {
|
||||||
60000: '#atm.idle',
|
60000: '#atm.idle',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@ export interface ATMContext {
|
||||||
// Hardware state
|
// Hardware state
|
||||||
/** Bills inserted during cash-in */
|
/** Bills inserted during cash-in */
|
||||||
billsInserted: number[]
|
billsInserted: number[]
|
||||||
|
/**
|
||||||
|
* Bill in flight: stack commanded, awaiting the validator's
|
||||||
|
* stacked-confirmation. Credit (BILL_INSERTED) only lands once the
|
||||||
|
* bill physically reached the stacker; FINISH_INSERTING is blocked
|
||||||
|
* while a bill is in flight (legacy brain.js 'billsRead' interlock).
|
||||||
|
*/
|
||||||
|
billPending: number | null
|
||||||
/** Whether cash has been dispensed */
|
/** Whether cash has been dispensed */
|
||||||
cashDispensed: boolean
|
cashDispensed: boolean
|
||||||
/** Dispense amounts for cash-out */
|
/** Dispense amounts for cash-out */
|
||||||
|
|
@ -140,6 +147,7 @@ export type ATMEvent =
|
||||||
| { type: 'CLEAR_SELECTION' }
|
| { type: 'CLEAR_SELECTION' }
|
||||||
| { type: 'CONFIRM_AMOUNT' }
|
| { type: 'CONFIRM_AMOUNT' }
|
||||||
// Hardware events
|
// Hardware events
|
||||||
|
| { type: 'BILL_PENDING'; denomination: number }
|
||||||
| { type: 'BILL_INSERTED'; denomination: number }
|
| { type: 'BILL_INSERTED'; denomination: number }
|
||||||
| { type: 'BILL_REJECTED'; reason: string }
|
| { type: 'BILL_REJECTED'; reason: string }
|
||||||
| { type: 'CASH_DISPENSED' }
|
| { type: 'CASH_DISPENSED' }
|
||||||
|
|
@ -187,6 +195,7 @@ export const initialContext: ATMContext = {
|
||||||
paymentMethod: null,
|
paymentMethod: null,
|
||||||
pendingOfferRequest: null,
|
pendingOfferRequest: null,
|
||||||
billsInserted: [],
|
billsInserted: [],
|
||||||
|
billPending: null,
|
||||||
cashDispensed: false,
|
cashDispensed: false,
|
||||||
dispenseAmounts: [],
|
dispenseAmounts: [],
|
||||||
cashOutSelection: [],
|
cashOutSelection: [],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue