Implement cash-out "Sell Bitcoin" flow with denomination-aware amount selection #11

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

Migrated from aiolabs/lamassu-next#11 — opened by @padreug on 2026-01-25.\n\n## Overview

Implement the complete "Sell Bitcoin" (cash-out) flow where users pay sats to receive physical cash from the ATM.

User Flow

  1. Select Amount - User selects a fiat amount (e.g., $80) based on available bill denominations
  2. Display Payment QR - ATM shows a noffer/invoice QR code for the sats equivalent (based on exchange rate)
  3. User Pays - User scans QR with their wallet and pays the invoice
  4. Dispense Cash - Upon payment confirmation, ATM dispenses the selected amount in bills

Key Requirements

Denomination-Aware Amount Selection

Critical: Users must only be able to request amounts that are physically dispensable given:

  • The bill denominations the machine supports (e.g., $20, $50, $100)
  • The actual bill inventory currently in the machine

For example:

  • Machine has 10x $20 bills and 5x $50 bills
  • Valid amounts: $20, $40, $50, $60, $70, $80, ... up to $450
  • Invalid: $30 (no $10 bills), $500 (exceeds inventory)

Reference: lamassu-server Implementation

The original lamassu-server has solved this problem. Key files to reference:

  • lamassu-server/lib/cash-out/ - Cash-out logic and denomination handling
  • lamassu-server/lib/dispenser.js - Bill dispenser abstraction
  • lamassu-machine/lib/puloon/ - Puloon dispenser driver with cassette management
  • Database tables: cash_out_txs, bills, devices (cassette configuration)

The solution involves:

  1. Tracking cassette inventory (which denominations, how many bills)
  2. Greedy algorithm to determine if an amount is dispensable
  3. UI shows only valid amount increments
  4. Real-time inventory updates after each dispense

UI Components

  • Amount selector with denomination-aware increments (+$20, +$50, etc.)
  • Running total display with sats equivalent
  • "Maximum available" indicator based on current inventory
  • QR code display for noffer/invoice
  • Payment status indicator
  • Dispense progress/confirmation

State Machine States

cashOut:
  selectingAmount → generatingOffer → displayingQR → awaitingPayment → 
  validatingPayment → dispensing → askForReceipt → complete

Services Required

  • getDispensableAmounts() - Returns valid fiat amounts based on inventory
  • calculateBillCombination(amount) - Returns which bills to dispense
  • generateNoffer(amountSats) - Creates payment QR (already implemented)
  • dispenseCash(bills[]) - Interface with HAL dispenser driver
  • updateInventory(dispensed[]) - Track remaining bills

Acceptance Criteria

  • User can only select physically dispensable amounts
  • Amount selection respects actual machine inventory
  • Exchange rate is fetched and displayed
  • Noffer QR code generated with correct sats amount
  • Payment detection via CLINK Kind 21001 response
  • Cash dispensed only after payment confirmed
  • Inventory updated after successful dispense
  • Error handling for dispense failures (refund flow)

Technical Notes

  • The noffer-based cash-out flow is already partially implemented (CashOutView.vue)
  • HAL dispenser drivers will be ported from lamassu-machine (Rust)
  • For dev/testing, use mock dispenser that logs bill counts
  • Cash-in (Buy Bitcoin) flow: ndebit-based, implemented in cf05373
  • CLINK noffer implementation in @lamassu/clink
  • State machine in @lamassu/state-machine
> _Migrated from [aiolabs/lamassu-next#11](https://git.atitlan.io/aiolabs/lamassu-next/issues/11) — opened by @padreug on 2026-01-25._\n\n## Overview Implement the complete "Sell Bitcoin" (cash-out) flow where users pay sats to receive physical cash from the ATM. ## User Flow 1. **Select Amount** - User selects a fiat amount (e.g., $80) based on available bill denominations 2. **Display Payment QR** - ATM shows a noffer/invoice QR code for the sats equivalent (based on exchange rate) 3. **User Pays** - User scans QR with their wallet and pays the invoice 4. **Dispense Cash** - Upon payment confirmation, ATM dispenses the selected amount in bills ## Key Requirements ### Denomination-Aware Amount Selection **Critical:** Users must only be able to request amounts that are physically dispensable given: - The bill denominations the machine supports (e.g., $20, $50, $100) - The actual bill inventory currently in the machine For example: - Machine has 10x $20 bills and 5x $50 bills - Valid amounts: $20, $40, $50, $60, $70, $80, ... up to $450 - Invalid: $30 (no $10 bills), $500 (exceeds inventory) ### Reference: lamassu-server Implementation The original `lamassu-server` has solved this problem. Key files to reference: - `lamassu-server/lib/cash-out/` - Cash-out logic and denomination handling - `lamassu-server/lib/dispenser.js` - Bill dispenser abstraction - `lamassu-machine/lib/puloon/` - Puloon dispenser driver with cassette management - Database tables: `cash_out_txs`, `bills`, `devices` (cassette configuration) The solution involves: 1. Tracking cassette inventory (which denominations, how many bills) 2. Greedy algorithm to determine if an amount is dispensable 3. UI shows only valid amount increments 4. Real-time inventory updates after each dispense ### UI Components - Amount selector with denomination-aware increments (+$20, +$50, etc.) - Running total display with sats equivalent - "Maximum available" indicator based on current inventory - QR code display for noffer/invoice - Payment status indicator - Dispense progress/confirmation ### State Machine States ``` cashOut: selectingAmount → generatingOffer → displayingQR → awaitingPayment → validatingPayment → dispensing → askForReceipt → complete ``` ### Services Required - `getDispensableAmounts()` - Returns valid fiat amounts based on inventory - `calculateBillCombination(amount)` - Returns which bills to dispense - `generateNoffer(amountSats)` - Creates payment QR (already implemented) - `dispenseCash(bills[])` - Interface with HAL dispenser driver - `updateInventory(dispensed[])` - Track remaining bills ## Acceptance Criteria - [ ] User can only select physically dispensable amounts - [ ] Amount selection respects actual machine inventory - [ ] Exchange rate is fetched and displayed - [ ] Noffer QR code generated with correct sats amount - [ ] Payment detection via CLINK Kind 21001 response - [ ] Cash dispensed only after payment confirmed - [ ] Inventory updated after successful dispense - [ ] Error handling for dispense failures (refund flow) ## Technical Notes - The noffer-based cash-out flow is already partially implemented (CashOutView.vue) - HAL dispenser drivers will be ported from lamassu-machine (Rust) - For dev/testing, use mock dispenser that logs bill counts ## Related - Cash-in (Buy Bitcoin) flow: ndebit-based, implemented in cf05373 - CLINK noffer implementation in `@lamassu/clink` - State machine in `@lamassu/state-machine`
Author
Owner

@padreug commented on 2026-01-26 (lamassu-next#11):

Implementation Plan (Refined)

After reviewing lamassu-machine/server and our existing invoice monitoring, here's the refined approach:

Key Decision: Direct BOLT11 Invoice (not spontaneous noffer)

The current implementation shows a "spontaneous" noffer where the user enters the amount in their wallet. This is problematic because:

  • User doesn't know what amounts are dispensable
  • Trial and error with invalid amounts = poor UX

New approach: ATM-driven amount selection with direct BOLT11 invoice.

State Machine Flow

cashOut:
  fetchingRate → selectingAmount → generatingInvoice → displayingInvoice → 
  awaitingPayment → dispensingCash → waitingForCashTaken → complete

Implementation Phases

Phase 1 (MVP):

  • Add selectingAmount state to state machine
  • Mock inventory: single denomination ($20), fixed count
  • UI: denomination buttons, running total, sats equivalent
  • Generate BOLT11 invoice via lightningPub.createInvoice()
  • Monitor payment via lightningPub.watchInvoice() (already implemented, polls every 2s)
  • Trigger PAYMENT_RECEIVED event → dispense

Phase 2:

  • Multiple denominations with coin-change validation
  • computeCashOut() service returning activeMap for button enable/disable
  • Transaction limits integration

Phase 3:

  • Real inventory from HAL/database
  • Inventory updates after dispense

Reference: lamassu-machine Key Patterns

From lamassu-machine/lib/brain.js and lib/tx.js:

  • Tx.computeCashOut(tx, units, virtualUnits, txLimit) - validates denominations
  • coin-change.js - greedy solver for bill combinations
  • activeMap - dictionary of {denomination: boolean} for UI button states
  • 3 denomination buttons shown from configured cassettes

Invoice Monitoring (Already Available)

// In @lamassu/lightning
lightningPub.watchInvoice(paymentHash, (paidInvoice) => {
  // Fires when invoice is paid
  // paidInvoice.preimage = proof of payment
})

Polls lookupInvoice() via Kind 21000 RPC every 2 seconds.


Starting implementation now.

> _@padreug commented on 2026-01-26 ([lamassu-next#11](https://git.atitlan.io/aiolabs/lamassu-next/issues/11#issuecomment-59)):_ ## Implementation Plan (Refined) After reviewing lamassu-machine/server and our existing invoice monitoring, here's the refined approach: ### Key Decision: Direct BOLT11 Invoice (not spontaneous noffer) The current implementation shows a "spontaneous" noffer where the user enters the amount in their wallet. This is problematic because: - User doesn't know what amounts are dispensable - Trial and error with invalid amounts = poor UX **New approach:** ATM-driven amount selection with direct BOLT11 invoice. ### State Machine Flow ``` cashOut: fetchingRate → selectingAmount → generatingInvoice → displayingInvoice → awaitingPayment → dispensingCash → waitingForCashTaken → complete ``` ### Implementation Phases **Phase 1 (MVP):** - [ ] Add `selectingAmount` state to state machine - [ ] Mock inventory: single denomination ($20), fixed count - [ ] UI: denomination buttons, running total, sats equivalent - [ ] Generate BOLT11 invoice via `lightningPub.createInvoice()` - [ ] Monitor payment via `lightningPub.watchInvoice()` (already implemented, polls every 2s) - [ ] Trigger `PAYMENT_RECEIVED` event → dispense **Phase 2:** - [ ] Multiple denominations with coin-change validation - [ ] `computeCashOut()` service returning `activeMap` for button enable/disable - [ ] Transaction limits integration **Phase 3:** - [ ] Real inventory from HAL/database - [ ] Inventory updates after dispense ### Reference: lamassu-machine Key Patterns From `lamassu-machine/lib/brain.js` and `lib/tx.js`: - `Tx.computeCashOut(tx, units, virtualUnits, txLimit)` - validates denominations - `coin-change.js` - greedy solver for bill combinations - `activeMap` - dictionary of `{denomination: boolean}` for UI button states - 3 denomination buttons shown from configured cassettes ### Invoice Monitoring (Already Available) ```typescript // In @lamassu/lightning lightningPub.watchInvoice(paymentHash, (paidInvoice) => { // Fires when invoice is paid // paidInvoice.preimage = proof of payment }) ``` Polls `lookupInvoice()` via Kind 21000 RPC every 2 seconds. --- Starting implementation now.
Author
Owner

@padreug commented on 2026-01-26 (lamassu-next#11):

Progress Update: Invoice Payment Detection Fixed

Commit 7defc10 fixes a critical bug in the cash-out flow where invoice payments weren't being detected.

Problem

GetPaymentState RPC was returning "invoice not found" even after invoices were successfully paid. This caused the ATM to hang after creating an invoice for the customer.

Root Cause

GetPaymentState is for checking outgoing payments (invoices you've paid to others), not incoming payments (invoices you've created that others pay).

Lightning.Pub's code path:

// paymentManager.ts - GetPaymentState
const invoice = await this.storage.paymentStorage.GetPaymentOwner(req.invoice)
// GetPaymentOwner looks in PAYMENT storage, not INVOICE storage!

Solution

Rewrote watchInvoice() to use subscription-based detection via GetLiveUserOperations:

  1. Subscribe to kind 21000 events from Lightning.Pub
  2. Filter for events with requestId === "GetLiveUserOperations"
  3. Match operation.type === "INCOMING_INVOICE" and compare invoice identifiers
  4. Invoke callback when payment is detected

Documentation

Added Issue #8 and #9 to packages/lightning/TROUBLESHOOTING.md documenting this gotcha for future reference. Total debugging time documented: ~5+ hours. Following the troubleshooting guide should reduce that to ~15 minutes.

Testing

Verified end-to-end with alice-pay - the ATM now correctly detects when an invoice is paid and completes the cash-out flow.

> _@padreug commented on 2026-01-26 ([lamassu-next#11](https://git.atitlan.io/aiolabs/lamassu-next/issues/11#issuecomment-61)):_ ## Progress Update: Invoice Payment Detection Fixed Commit [7defc10](https://git.atitlan.io/aiolabs/lamassu-next/commit/7defc10) fixes a critical bug in the cash-out flow where invoice payments weren't being detected. ### Problem `GetPaymentState` RPC was returning "invoice not found" even after invoices were successfully paid. This caused the ATM to hang after creating an invoice for the customer. ### Root Cause `GetPaymentState` is for checking **outgoing payments** (invoices you've paid to others), not incoming payments (invoices you've created that others pay). Lightning.Pub's code path: ```typescript // paymentManager.ts - GetPaymentState const invoice = await this.storage.paymentStorage.GetPaymentOwner(req.invoice) // GetPaymentOwner looks in PAYMENT storage, not INVOICE storage! ``` ### Solution Rewrote `watchInvoice()` to use subscription-based detection via `GetLiveUserOperations`: 1. Subscribe to kind 21000 events from Lightning.Pub 2. Filter for events with `requestId === "GetLiveUserOperations"` 3. Match `operation.type === "INCOMING_INVOICE"` and compare invoice identifiers 4. Invoke callback when payment is detected ### Documentation Added Issue #8 and #9 to `packages/lightning/TROUBLESHOOTING.md` documenting this gotcha for future reference. Total debugging time documented: ~5+ hours. Following the troubleshooting guide should reduce that to ~15 minutes. ### Testing Verified end-to-end with `alice-pay` - the ATM now correctly detects when an invoice is paid and completes the cash-out flow.
Author
Owner

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

This issue currently mixes two distinct concerns:

  1. Generate a noffer (CLINK kind-21001). noffer is a Lightning.Pub-specific event type in the CLINK protocol (https://github.com/shocknet/CLINK). LNbits has no CLINK support and the nostr-native-transport work (see #22) does not add it. Generating and broadcasting noffers stays on LP (or in CLINK-aware client code, depending on which side mints them).

  2. Detect that the customer paid the invoice underneath the noffer. This part has clean LNbits coverage post-migration:

    • Invoice creation: create_invoice({amount, memo, extra?}) over the nostr transport, against the ATM's LNbits wallet. Returns the BOLT11 the noffer would point to.
    • Settlement detection: subscribe_payments({wallet_id: <atm>, payment_hash: <hash>}) over the same transport — real-time encrypted push the moment the invoice settles. Already exercised end-to-end in the LNbits driver script --flow subscribe.

Recommended split:

  • Keep this issue as the cash-out flow design — denomination-aware amount selection, UX, state machine, etc.
  • Carve out a sub-issue (or close-out comment here) for noffer generation — LP-side only.
  • For the invoice + payment-detect layer, use the LNbits transport RPCs directly. No need to wait on hold-invoice work either (lamassu-next#3 was about hold-invoice security on LP; not applicable to LNbits unless we add create_hold_invoice / settle_hold_invoice / cancel_hold_invoice to the transport — which is a separate open design decision, currently absent).

Optional session correlation: the extra dict on create_invoice is opaque to LNbits; pass extra={"session_id": "..."} and the value rides through to the subscribe_payments push (Payment.extra is preserved unchanged). Lets the ATM correlate any of N concurrent cash-out sessions without amount/timing heuristics.

Net: the LNbits side of this work is ready to consume. The noffer/CLINK side stays on LP and is not blocked by anything in the LNbits transport.

> _@padreug commented on 2026-05-13 ([lamassu-next#11](https://git.atitlan.io/aiolabs/lamassu-next/issues/11#issuecomment-567)):_ ## Split this into CLINK-side (noffer) and LNbits-side (invoice + subscription) work This issue currently mixes two distinct concerns: 1. **Generate a noffer (CLINK kind-21001).** noffer is a Lightning.Pub-specific event type in the CLINK protocol (https://github.com/shocknet/CLINK). **LNbits has no CLINK support** and the `nostr-native-transport` work (see #22) does not add it. Generating and broadcasting noffers stays on LP (or in CLINK-aware client code, depending on which side mints them). 2. **Detect that the customer paid the invoice underneath the noffer.** This part has clean LNbits coverage post-migration: - **Invoice creation:** `create_invoice({amount, memo, extra?})` over the nostr transport, against the ATM's LNbits wallet. Returns the BOLT11 the noffer would point to. - **Settlement detection:** `subscribe_payments({wallet_id: <atm>, payment_hash: <hash>})` over the same transport — real-time encrypted push the moment the invoice settles. Already exercised end-to-end in the LNbits driver script `--flow subscribe`. **Recommended split:** - Keep this issue as the *cash-out flow design* — denomination-aware amount selection, UX, state machine, etc. - Carve out a sub-issue (or close-out comment here) for **noffer generation** — LP-side only. - For the **invoice + payment-detect** layer, use the LNbits transport RPCs directly. No need to wait on hold-invoice work either (lamassu-next#3 was about hold-invoice security on LP; not applicable to LNbits unless we add `create_hold_invoice` / `settle_hold_invoice` / `cancel_hold_invoice` to the transport — which is a separate open design decision, currently absent). **Optional session correlation:** the `extra` dict on `create_invoice` is opaque to LNbits; pass `extra={"session_id": "..."}` and the value rides through to the `subscribe_payments` push (Payment.extra is preserved unchanged). Lets the ATM correlate any of N concurrent cash-out sessions without amount/timing heuristics. **Net:** the LNbits side of this work is ready to consume. The noffer/CLINK side stays on LP and is not blocked by anything in the LNbits transport.
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#11
No description provided.