Improve transaction limit enforcement for cash-in and cash-out #35

Open
opened 2026-06-13 22:02:55 +00:00 by padreug · 2 comments
Owner

Migrated from aiolabs/lamassu-next#35 — opened by @padreug on 2026-03-01.\n\n## Summary

lamassu-next has basic per-bill validation for cash-in and per-denomination inventory checks for cash-out, but there are gaps compared to the legacy lamassu-machine that could lead to users inserting cash beyond available BTC or selecting undispensable amounts.

Cash-In Issues

1. Bills accepted when rate/balance unknown

When exchangeRate === 0 or availableBalance <= 0, the guard returns true — bills are accepted without limit.

File: apps/machine/src/views/CashInView.vue:137-145

function canAcceptBill(denomination: number): boolean {
  if (ctx.availableBalance <= 0 || ctx.exchangeRate === 0) return true  // ← dangerous
  ...
}

Fix: Reject bills if rate or balance is unknown. Show "Temporarily unavailable" instead of accepting unlimited cash.

2. Weak UX on bill rejection

When a bill exceeds the remaining balance, it's physically rejected but the user gets minimal feedback.

Legacy behavior (brain.js:3014-3020): Shows a "highBill" dialog telling the user the maximum denomination they can still insert, with reason 'lowBalance'.

lamassu-next: Just shows "Maximum amount reached" text. No indication of which bills ARE still accepted.

Fix: Calculate and display the highest acceptable denomination when rejecting a bill.

3. Balance fetched once, never refreshed

lamassu-next fetches availableBalance once at the start of the transaction (machine.ts:74-84). If the balance changes during a long transaction (e.g., another user is also cashing in on the same LP), the local check becomes stale.

Legacy behavior (brain.js:2701-2706): Continuously polls balance from the trader/server during the transaction.

Fix: Poll LP balance periodically during insertingBills state, or at minimum re-check before each bill acceptance.

Cash-Out Issues

4. No coin-change validation

lamassu-next uses simple per-denomination inventory checks: selected[denom] < inventory[denom]. This doesn't validate that the total selected amount is actually dispensable as a combination of available bills.

Legacy behavior (tx.js:166-190): Uses a coin-change algorithm that:

  • Builds a model from cassette inventory
  • Tests if the requested total is achievable with available denominations
  • Pre-disables denomination buttons that would create an undispensable amount
  • Shows "outOfCash" when no valid combinations exist

Fix: Add coin-change validation when the user confirms their selection, or better yet, pre-validate on each denomination addition (like legacy does).

5. No "out of cash" state

If all cassettes are empty, lamassu-next stays in selectingAmount with all buttons disabled but no explicit message.

Legacy behavior (brain.js:3871): Transitions to a dedicated outOfCash timed state with a clear message, then returns to idle.

Fix: Add an outOfCash state or guard that prevents entering cash-out flow when inventory is empty.

Edge Cases

Scenario lamassu-next Legacy Expected
Rate fetch fails Accept all bills Accept all bills Reject all bills
Balance = 0 Accept all bills Reject + show reason Reject + show reason
Q200 bill, only 15k sats left Reject (guard works) Reject + show max denom Reject + show max denom
All cassettes empty Silent disabled buttons "Out of cash" screen "Out of cash" screen
Partial cassette (Q100 empty, Q200 has stock) Q100 button disabled Q100 button disabled + coin-change check Same + coin-change

Proposed Fixes

  1. Reject bills when rate/balance unknown — don't default to true
  2. Show highest acceptable denomination on bill rejection (backport legacy "highBill" UX)
  3. Periodic balance refresh during cash-in insertingBills state
  4. Coin-change validation for cash-out denomination selection
  5. Dedicated "out of cash" state with clear user messaging
  6. Compliance/per-transaction limits — legacy has getAmountToHardLimit() for regulatory caps; lamassu-next has none

References

  • lamassu-next bill guard: packages/state-machine/src/machine.ts:350-357
  • lamassu-next cash-out view: apps/machine/src/views/CashOutView.vue:48-77
  • Legacy bill validation: lamassu-machine/lib/brain.js:2964-3070
  • Legacy coin-change: lamassu-machine/lib/tx.js:166-190
  • Legacy denomination selection: lamassu-machine/lib/brain.js:3847-3871
  • Related: #34 (error handling backport)
> _Migrated from [aiolabs/lamassu-next#35](https://git.atitlan.io/aiolabs/lamassu-next/issues/35) — opened by @padreug on 2026-03-01._\n\n## Summary lamassu-next has basic per-bill validation for cash-in and per-denomination inventory checks for cash-out, but there are gaps compared to the legacy lamassu-machine that could lead to users inserting cash beyond available BTC or selecting undispensable amounts. ## Cash-In Issues ### 1. Bills accepted when rate/balance unknown When `exchangeRate === 0` or `availableBalance <= 0`, the guard returns `true` — bills are accepted without limit. **File**: `apps/machine/src/views/CashInView.vue:137-145` ```typescript function canAcceptBill(denomination: number): boolean { if (ctx.availableBalance <= 0 || ctx.exchangeRate === 0) return true // ← dangerous ... } ``` **Fix**: Reject bills if rate or balance is unknown. Show "Temporarily unavailable" instead of accepting unlimited cash. ### 2. Weak UX on bill rejection When a bill exceeds the remaining balance, it's physically rejected but the user gets minimal feedback. **Legacy behavior** (`brain.js:3014-3020`): Shows a "highBill" dialog telling the user the **maximum denomination** they can still insert, with reason `'lowBalance'`. **lamassu-next**: Just shows "Maximum amount reached" text. No indication of which bills ARE still accepted. **Fix**: Calculate and display the highest acceptable denomination when rejecting a bill. ### 3. Balance fetched once, never refreshed lamassu-next fetches `availableBalance` once at the start of the transaction (`machine.ts:74-84`). If the balance changes during a long transaction (e.g., another user is also cashing in on the same LP), the local check becomes stale. **Legacy behavior** (`brain.js:2701-2706`): Continuously polls balance from the trader/server during the transaction. **Fix**: Poll LP balance periodically during `insertingBills` state, or at minimum re-check before each bill acceptance. ## Cash-Out Issues ### 4. No coin-change validation lamassu-next uses simple per-denomination inventory checks: `selected[denom] < inventory[denom]`. This doesn't validate that the **total selected amount** is actually dispensable as a combination of available bills. **Legacy behavior** (`tx.js:166-190`): Uses a coin-change algorithm that: - Builds a model from cassette inventory - Tests if the requested total is achievable with available denominations - Pre-disables denomination buttons that would create an undispensable amount - Shows "outOfCash" when no valid combinations exist **Fix**: Add coin-change validation when the user confirms their selection, or better yet, pre-validate on each denomination addition (like legacy does). ### 5. No "out of cash" state If all cassettes are empty, lamassu-next stays in `selectingAmount` with all buttons disabled but no explicit message. **Legacy behavior** (`brain.js:3871`): Transitions to a dedicated `outOfCash` timed state with a clear message, then returns to idle. **Fix**: Add an `outOfCash` state or guard that prevents entering cash-out flow when inventory is empty. ## Edge Cases | Scenario | lamassu-next | Legacy | Expected | |----------|-------------|--------|----------| | Rate fetch fails | Accept all bills | Accept all bills | Reject all bills | | Balance = 0 | Accept all bills | Reject + show reason | Reject + show reason | | Q200 bill, only 15k sats left | Reject (guard works) | Reject + show max denom | Reject + show max denom | | All cassettes empty | Silent disabled buttons | "Out of cash" screen | "Out of cash" screen | | Partial cassette (Q100 empty, Q200 has stock) | Q100 button disabled | Q100 button disabled + coin-change check | Same + coin-change | ## Proposed Fixes 1. **Reject bills when rate/balance unknown** — don't default to `true` 2. **Show highest acceptable denomination** on bill rejection (backport legacy "highBill" UX) 3. **Periodic balance refresh** during cash-in `insertingBills` state 4. **Coin-change validation** for cash-out denomination selection 5. **Dedicated "out of cash" state** with clear user messaging 6. **Compliance/per-transaction limits** — legacy has `getAmountToHardLimit()` for regulatory caps; lamassu-next has none ## References - lamassu-next bill guard: `packages/state-machine/src/machine.ts:350-357` - lamassu-next cash-out view: `apps/machine/src/views/CashOutView.vue:48-77` - Legacy bill validation: `lamassu-machine/lib/brain.js:2964-3070` - Legacy coin-change: `lamassu-machine/lib/tx.js:166-190` - Legacy denomination selection: `lamassu-machine/lib/brain.js:3847-3871` - Related: #34 (error handling backport)
Author
Owner

@padreug commented on 2026-05-13 (lamassu-next#35):

LNbits side: get_wallet + push events from subscribe_payments

The "periodic balance refresh during cash-in" need maps cleanly to the LNbits transport (see #22):

Pull side — get_wallet over nostr:

get_wallet  (AUTH_WALLET, body empty)
  -> {id, name, balance}

Returns the wallet's current balance_msat. Cheap call; safe to invoke once per bill accept (or on a 5-10 second interval during cash-in). Equivalent of LP's GetUserInfo for the balance-only case.

Push side — subscribe_payments already covers the right surface:
The recently-shipped outgoing-payment fanout (LNbits commit 085fd501) means the ATM's subscription matches both directions on a wallet. So subscribe_payments({wallet_id: <atm_wallet>}) (with no payment_hash filter — just wallet scope) streams every settlement on the wallet in real time. Each push carries the full Payment object including amount and direction; the ATM derives the new balance from the running tally without a separate get_wallet poll between bills.

Recommended cash-in flow:

  1. On session start: get_wallet → cache the baseline balance_msat.
  2. Open subscribe_payments({wallet_id}) for the duration of the session.
  3. For each push event: update the cached balance (add for incoming, subtract for outgoing). Compute the next-bill-limit from the cached number.
  4. On session end: unsubscribe.

This eliminates the "bills accepted without a fresh rate/balance check" race the issue describes — the balance is always current to the last settled payment, with no polling latency.

Optional safety net: still call get_wallet periodically (every 60-120s) as a reconciliation guard against any subscription drop / missed event. The extra dict on each payment also gives you a place to thread session correlation if multiple ATMs share a wallet.

Net: LNbits side is in place. No new RPCs needed for this issue.

> _@padreug commented on 2026-05-13 ([lamassu-next#35](https://git.atitlan.io/aiolabs/lamassu-next/issues/35#issuecomment-576)):_ ## LNbits side: `get_wallet` + push events from `subscribe_payments` The "periodic balance refresh during cash-in" need maps cleanly to the LNbits transport (see #22): **Pull side — `get_wallet` over nostr:** ``` get_wallet (AUTH_WALLET, body empty) -> {id, name, balance} ``` Returns the wallet's current `balance_msat`. Cheap call; safe to invoke once per bill accept (or on a 5-10 second interval during cash-in). Equivalent of LP's `GetUserInfo` for the balance-only case. **Push side — `subscribe_payments` already covers the right surface:** The recently-shipped outgoing-payment fanout (LNbits commit `085fd501`) means the ATM's subscription matches **both** directions on a wallet. So `subscribe_payments({wallet_id: <atm_wallet>})` (with no `payment_hash` filter — just wallet scope) streams every settlement on the wallet in real time. Each push carries the full `Payment` object including amount and direction; the ATM derives the new balance from the running tally without a separate `get_wallet` poll between bills. **Recommended cash-in flow:** 1. On session start: `get_wallet` → cache the baseline `balance_msat`. 2. Open `subscribe_payments({wallet_id})` for the duration of the session. 3. For each push event: update the cached balance (add for incoming, subtract for outgoing). Compute the next-bill-limit from the cached number. 4. On session end: unsubscribe. This eliminates the "bills accepted without a fresh rate/balance check" race the issue describes — the balance is always current to the last settled payment, with no polling latency. Optional safety net: still call `get_wallet` periodically (every 60-120s) as a reconciliation guard against any subscription drop / missed event. The `extra` dict on each payment also gives you a place to thread session correlation if multiple ATMs share a wallet. **Net:** LNbits side is in place. No new RPCs needed for this issue.
Author
Owner

Status check against current dev (2026-07-04 review): the cash-in section is now resolved.

  • The state-machine guard and CashInView.canAcceptBill were already fail-closed on dev (both reject when exchangeRate === 0 || availableBalance <= 0) — the issue's cited fail-open guard no longer matches the code.
  • The one remaining fail-open point was the escrow decision in stores/atm.ts (onHalBillRead: "no rate yet, accept anyway" → stack) — the layer that physically takes the bill. PR #77 makes it fail-closed (unknown rate/balance, or machine not in insertingBills → bill returned to customer) and moves balance gating to the pre-stack BILL_PENDING guard.

Cash-out-side items in this issue (denomination-aware selection limits etc.) were not re-verified in this pass and may still stand.

Status check against current `dev` (2026-07-04 review): the cash-in section is now resolved. - The state-machine guard and `CashInView.canAcceptBill` were already fail-closed on dev (both reject when `exchangeRate === 0 || availableBalance <= 0`) — the issue's cited fail-open guard no longer matches the code. - The one remaining fail-open point was the *escrow decision* in `stores/atm.ts` (`onHalBillRead`: "no rate yet, accept anyway" → stack) — the layer that physically takes the bill. PR #77 makes it fail-closed (unknown rate/balance, or machine not in `insertingBills` → bill returned to customer) and moves balance gating to the pre-stack `BILL_PENDING` guard. Cash-out-side items in this issue (denomination-aware selection limits etc.) were not re-verified in this pass and may still stand.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire#35
No description provided.