S8 — Wire cash-in path (LNURL-withdraw outbound + naming hygiene) #22

Closed
opened 2026-05-15 18:14:16 +00:00 by padreug · 2 comments
Owner

Part of #13. Closes gap G10 (cash-in path is not wired).

Problem

tasks.py:_handle_payment filters is_in=True only (tasks.py:57). Cash-in transactions never fire the listener because they're outbound on the operator's wallet (LNbits pays an LNURL-withdraw the customer scanned at the ATM). Today a real cash-in lands nowhere in satmachineadmin — we'd never know it happened, and the customer's LP balance / commission split / super fee never get computed.

The stale-npub1111… incident from the security review was a symptom of this: an LNURL-withdraw left Greg's wallet correctly (Sintra), but because we didn't catch the outbound, the inbound on the redeemer wallet (a test user with a stale dca_machines row) was the only event we processed — and we filed it as a cash-out on the wrong machine.

Vocabulary trap (file this in code review)

Payment.is_in is a LNbits protocol direction ("this wallet received sats"), not a bitSpire business direction.

Business term (bitSpire) Customer hands ATM payment.is_in on operator wallet
cash-out BTC over Lightning True (operator wallet receives BTC)
cash-in physical cash False (operator wallet sends BTC via LNURL-withdraw)

In this repo we will:

  • Never use bare is_in / is_out to mean the business direction.
  • Local variables in tasks.py consume payment.is_in as is_lightning_inbound; explicit second branch as is_lightning_outbound.
  • dca_settlements.tx_type ∈ {"cash_in","cash_out"} is the source of truth for business direction.
  • Add docstrings on every branch that conditions on payment.is_in spelling out which business direction it catches.

(Already captured as a feedback memory; this issue is where the convention lands in code.)

Changes

tasks.py

  • Drop the if not payment.is_in: return early-out.
  • Branch on payment.is_in:
    • is_lightning_inbound → cash-out path (existing). tx_type="cash_out".
    • is_lightning_outbound → cash-in path (new). tx_type="cash_in".
  • Both branches resolve the machine via get_active_machine_by_wallet_id(payment.wallet_id) — but cash-in has additional gating (see below).

Cash-in routing

When an outbound payment fires on a machine-owned wallet, we need to know it was triggered by an LNURL-withdraw the ATM published, not by some other thing the operator did with their wallet (a manual send, a different extension, etc.). Discriminators:

  1. Payment.extra source marker. bitSpire populates extra.source = "bitspire" and extra.flow = "cash_in" on the LNURL-withdraw it generates (depends on aiolabs/lamassu-next#44).
  2. LNURL link tracking. The ATM creates the withdraw link through the LNbits withdraw extension; the resulting withdraw row carries a known extra payload our listener can correlate.
  3. Fallback: if extra.source != "bitspire", skip — this was the operator using their own wallet for something unrelated.

bitspire.parse_settlement

  • Extend to accept direction arg. For cash-in: read gross_sats from the outbound payment.sat, parse fiat_amount, fiat_code, exchange_rate, bills_inserted from Payment.extra.
  • Two-stage commission split applies identically. The leg payouts in distribution.py already work in both directions — DCA legs always go to LPs out of the operator wallet, super_fee + operator_splits come out of the same gross. Verify on the regtest stack.

LNURL-withdraw TTL

  • The ATM's kind-21000 RPC sets a session TTL (typical: 10 min for the customer to scan + pay). The LNURL-withdraw link satmachineadmin/LNbits issues for that session must carry the same expiration (NIP-40 expiration tag on the kind-21000 response + a matching webhook_url / expiry on the LNURL-withdraw row).
  • A previous real incident (May 2026): a long-expired ATM session's LNURL was still redeemable. The customer had walked away; the redemption silently drained sats long after the cash drawer interaction ended. With matched TTL this can't happen.

Settlement attestation for cash-in (S3 follow-on)

  • The NIP-57-style receipt event from S3 should be published for cash-in too — same kind, same tags, plus ["direction", "cash_in"]. Operator audit symmetry: every settlement (in either direction) has a public, preimage-anchored attestation.

Open questions

  • Refund queue for stale LNURLs. When an LNURL-withdraw expires before redemption, the customer paid cash they're owed back. We need a worklist on the operator dashboard surfacing these so the operator can hand the cash back at the kiosk. (Today: no such surface exists; the abandoned-tx queue from aiolabs/satmachineadmin#3 is the right home for this.)
  • bitSpire-side support. The flow depends on aiolabs/lamassu-next#44 populating extra.source = "bitspire" and extra.flow = "cash_in" on the cash-in LNURL-withdraw. Without it we fall back to a heuristic match (correlation by amount + time window) — possible but ugly.

Acceptance

  • End-to-end cash-in on the regtest stack: customer inserts cash → ATM publishes LNURL → customer's wallet redeems → operator wallet sends sats out → satmachineadmin lands a dca_settlements row with tx_type="cash_in".
  • LP balances, super fee, and operator splits compute correctly for cash-in (parity with cash-out).
  • Code review confirms no bare is_in / is_out synonyms for business direction; all branches explicitly named.
  • LNURL-withdraw TTL matches the kind-21000 session TTL; expired LNURLs cannot be redeemed.
  • Stale LNURLs surface in an operator worklist.
  • Negative test: customer using their own wallet for a non-ATM purpose (e.g. paying a different invoice) does not create a dca_settlements row.

Sequencing

Sprint 3, after S0–S5 land. Cash-in benefits from every security primitive in the epic (delegation, expiration, sender_pubkey, fleet-roster check), so doing it before they land would just mean re-walking the same code with each later phase.

Reference

Design doc: docs/security-pathway-v1.md §6.S8.
Upstream metadata: aiolabs/lamassu-next#44.
Adjacent: aiolabs/satmachineadmin#3 (abandoned/partial transaction queue — refund worklist lives there).
Naming convention memory: ~/.claude/projects/.../memory/feedback_naming_business_vs_protocol.md.

Part of #13. Closes gap G10 (cash-in path is not wired). ## Problem `tasks.py:_handle_payment` filters `is_in=True` only (`tasks.py:57`). Cash-in transactions never fire the listener because they're **outbound** on the operator's wallet (LNbits pays an LNURL-withdraw the customer scanned at the ATM). Today a real cash-in lands nowhere in satmachineadmin — we'd never know it happened, and the customer's LP balance / commission split / super fee never get computed. The stale-`npub1111…` incident from the security review was a symptom of this: an LNURL-withdraw left Greg's wallet correctly (Sintra), but because we didn't catch the outbound, the inbound on the *redeemer* wallet (a test user with a stale `dca_machines` row) was the only event we processed — and we filed it as a cash-out on the wrong machine. ## Vocabulary trap (file this in code review) `Payment.is_in` is a **LNbits protocol direction** ("this wallet received sats"), not a bitSpire **business direction**. | Business term (bitSpire) | Customer hands ATM | `payment.is_in` on operator wallet | |---|---|---| | **cash-out** | BTC over Lightning | `True` (operator wallet receives BTC) | | **cash-in** | physical cash | `False` (operator wallet sends BTC via LNURL-withdraw) | In this repo we will: - Never use bare `is_in` / `is_out` to mean the business direction. - Local variables in `tasks.py` consume `payment.is_in` as `is_lightning_inbound`; explicit second branch as `is_lightning_outbound`. - `dca_settlements.tx_type ∈ {"cash_in","cash_out"}` is the **source of truth** for business direction. - Add docstrings on every branch that conditions on `payment.is_in` spelling out which business direction it catches. (Already captured as a feedback memory; this issue is where the convention lands in code.) ## Changes ### `tasks.py` - Drop the `if not payment.is_in: return` early-out. - Branch on `payment.is_in`: - **`is_lightning_inbound`** → cash-out path (existing). `tx_type="cash_out"`. - **`is_lightning_outbound`** → cash-in path (new). `tx_type="cash_in"`. - Both branches resolve the machine via `get_active_machine_by_wallet_id(payment.wallet_id)` — but cash-in has additional gating (see below). ### Cash-in routing When an outbound payment fires on a machine-owned wallet, we need to know it was triggered by an **LNURL-withdraw the ATM published**, not by some other thing the operator did with their wallet (a manual send, a different extension, etc.). Discriminators: 1. **Payment.extra source marker.** bitSpire populates `extra.source = "bitspire"` and `extra.flow = "cash_in"` on the LNURL-withdraw it generates (depends on `aiolabs/lamassu-next#44`). 2. **LNURL link tracking.** The ATM creates the withdraw link through the LNbits `withdraw` extension; the resulting withdraw row carries a known `extra` payload our listener can correlate. 3. **Fallback:** if `extra.source != "bitspire"`, skip — this was the operator using their own wallet for something unrelated. ### `bitspire.parse_settlement` - Extend to accept `direction` arg. For cash-in: read `gross_sats` from the outbound `payment.sat`, parse `fiat_amount`, `fiat_code`, `exchange_rate`, `bills_inserted` from `Payment.extra`. - Two-stage commission split applies identically. The leg payouts in `distribution.py` already work in both directions — DCA legs always go to LPs out of the operator wallet, super_fee + operator_splits come out of the *same* gross. Verify on the regtest stack. ### LNURL-withdraw TTL - The ATM's kind-21000 RPC sets a session TTL (typical: 10 min for the customer to scan + pay). The LNURL-withdraw link satmachineadmin/LNbits issues for that session **must** carry the same expiration (`NIP-40` `expiration` tag on the kind-21000 response + a matching `webhook_url` / `expiry` on the LNURL-withdraw row). - A previous real incident (May 2026): a long-expired ATM session's LNURL was still redeemable. The customer had walked away; the redemption silently drained sats long after the cash drawer interaction ended. With matched TTL this can't happen. ### Settlement attestation for cash-in (S3 follow-on) - The NIP-57-style receipt event from S3 should be published for cash-in too — same kind, same tags, plus `["direction", "cash_in"]`. Operator audit symmetry: every settlement (in either direction) has a public, preimage-anchored attestation. ## Open questions - **Refund queue for stale LNURLs.** When an LNURL-withdraw expires before redemption, the customer paid cash they're owed back. We need a worklist on the operator dashboard surfacing these so the operator can hand the cash back at the kiosk. (Today: no such surface exists; the abandoned-tx queue from `aiolabs/satmachineadmin#3` is the right home for this.) - **bitSpire-side support.** The flow depends on `aiolabs/lamassu-next#44` populating `extra.source = "bitspire"` and `extra.flow = "cash_in"` on the cash-in LNURL-withdraw. Without it we fall back to a heuristic match (correlation by amount + time window) — possible but ugly. ## Acceptance - [ ] End-to-end cash-in on the regtest stack: customer inserts cash → ATM publishes LNURL → customer's wallet redeems → operator wallet sends sats out → satmachineadmin lands a `dca_settlements` row with `tx_type="cash_in"`. - [ ] LP balances, super fee, and operator splits compute correctly for cash-in (parity with cash-out). - [ ] Code review confirms no bare `is_in` / `is_out` synonyms for business direction; all branches explicitly named. - [ ] LNURL-withdraw TTL matches the kind-21000 session TTL; expired LNURLs cannot be redeemed. - [ ] Stale LNURLs surface in an operator worklist. - [ ] Negative test: customer using their own wallet for a non-ATM purpose (e.g. paying a different invoice) does **not** create a `dca_settlements` row. ## Sequencing Sprint 3, after S0–S5 land. Cash-in benefits from every security primitive in the epic (delegation, expiration, sender_pubkey, fleet-roster check), so doing it before they land would just mean re-walking the same code with each later phase. ## Reference Design doc: `docs/security-pathway-v1.md` §6.S8. Upstream metadata: `aiolabs/lamassu-next#44`. Adjacent: `aiolabs/satmachineadmin#3` (abandoned/partial transaction queue — refund worklist lives there). Naming convention memory: `~/.claude/projects/.../memory/feedback_naming_business_vs_protocol.md`.
Author
Owner

2026-05-26 — fold in unique content from closed #23

Re-reviewed #23 against this issue after closing it as a duplicate. Most content overlaps but three load-bearing items from #23 are unique and should be tracked here when picking up S8:

1. Branch discriminator: tx_type from Payment.extra, NOT payment.is_in

This issue's "Changes → tasks.py" section reads:

Branch on payment.is_in:

  • is_lightning_inbound → cash-out path
  • is_lightning_outbound → cash-in path

#23 proposed a stronger pattern:

Drop the is_in filter. Process both directions; branch on tx_type from Payment.extra (the bitSpire business-direction source of truth), with is_lightning_inbound / is_lightning_outbound only as a sanity cross-check (cash_out ↔ inbound, cash_in ↔ outbound).

The tx_type discriminator is more honest about what we're switching on — business direction comes from the wire field bitSpire stamps, not from a correlation with Lightning direction. Use is_in as a sanity check (assert is_lightning_inbound == (tx_type == "cash_out")); fail loudly if they ever disagree.

Adopt this design when picking up S8.

2. DCA leg should be SKIPPED (not zero-sized) for cash-in

Cash-in is liquidity coming in — there's nothing to distribute to LPs (no payout direction). The distribution code should explicitly skip the DCA leg for tx_type=cash_in, not produce a zero-sats dca row. This matches the existing status='skipped' convention from fix bundle 2 (#11 H5/M8).

Add to acceptance criteria.

3. UI: cash-in rows need a distinct row marker

The Settlements tab today renders one icon shape for all rows. For S8, cash-in rows need a visible distinguisher — an icon + a tx_type chip — so operators can tell at a glance which flow each row represents without reading JSON.

Add to acceptance criteria.

Other from #23, already covered by this issue

  • LNURL revocation gated on S1 (#15) — S1 is now closed, so this gating is satisfied; LNURL revocation can ship in the same PR as the rest of S8.
  • Commission split default for cash-in (= "same split table applies", document as open product input) — captured here under "Distribution legs" implicitly; flagging explicitly so it's not forgotten.
  • S3 receipt event should reference LNURL k1 (or its hash) + payment preimage for cash-in symmetry.

Suggested PR sequencing (carried from #23)

Two PRs, not one:

  1. Listener + naming + parse_settlement cash-in branch (no LNURL lifecycle changes). Ships the recording side; existing LNURL behaviour stays intact.
  2. LNURL revocation + TTL binding to NIP-40 expiration (depends on PR 1; unblocks the lifecycle acceptance criteria).

Splitting reduces blast radius — if PR 1 ships and PR 2 hits issues, the recording side is already live.


Net: my closure of #23 as dup was right in spirit but I missed these three substantive items. They're folded in here.

## 2026-05-26 — fold in unique content from closed #23 Re-reviewed #23 against this issue after closing it as a duplicate. Most content overlaps but **three load-bearing items from #23 are unique** and should be tracked here when picking up S8: ### 1. Branch discriminator: `tx_type` from Payment.extra, NOT `payment.is_in` This issue's "Changes → `tasks.py`" section reads: > Branch on `payment.is_in`: > - `is_lightning_inbound` → cash-out path > - `is_lightning_outbound` → cash-in path #23 proposed a stronger pattern: > Drop the `is_in` filter. Process both directions; **branch on `tx_type` from `Payment.extra`** (the bitSpire business-direction source of truth), with `is_lightning_inbound` / `is_lightning_outbound` only as a sanity cross-check (`cash_out ↔ inbound`, `cash_in ↔ outbound`). The `tx_type` discriminator is more honest about what we're switching on — business direction comes from the wire field bitSpire stamps, not from a correlation with Lightning direction. Use `is_in` as a sanity check (`assert is_lightning_inbound == (tx_type == "cash_out")`); fail loudly if they ever disagree. **Adopt this design when picking up S8.** ### 2. DCA leg should be SKIPPED (not zero-sized) for cash-in Cash-in is liquidity coming *in* — there's nothing to distribute to LPs (no payout direction). The distribution code should explicitly skip the DCA leg for `tx_type=cash_in`, not produce a zero-sats `dca` row. This matches the existing `status='skipped'` convention from fix bundle 2 (#11 H5/M8). **Add to acceptance criteria.** ### 3. UI: cash-in rows need a distinct row marker The Settlements tab today renders one icon shape for all rows. For S8, cash-in rows need a visible distinguisher — an icon + a `tx_type` chip — so operators can tell at a glance which flow each row represents without reading JSON. **Add to acceptance criteria.** ### Other from #23, already covered by this issue - LNURL revocation gated on S1 (#15) — S1 is now closed, so this gating is satisfied; LNURL revocation can ship in the same PR as the rest of S8. - Commission split default for cash-in (= "same split table applies", document as open product input) — captured here under "Distribution legs" implicitly; flagging explicitly so it's not forgotten. - S3 receipt event should reference LNURL `k1` (or its hash) + payment preimage for cash-in symmetry. ### Suggested PR sequencing (carried from #23) Two PRs, not one: 1. **Listener + naming + `parse_settlement` cash-in branch** (no LNURL lifecycle changes). Ships the recording side; existing LNURL behaviour stays intact. 2. **LNURL revocation + TTL binding to NIP-40 expiration** (depends on PR 1; unblocks the lifecycle acceptance criteria). Splitting reduces blast radius — if PR 1 ships and PR 2 hits issues, the recording side is already live. --- Net: my closure of #23 as dup was right in spirit but I missed these three substantive items. They're folded in here.
Author
Owner

Shipped — closing

Cash-in path landed on v2-bitspire at commit eca6e96 (feat(v2): wire cash-in routing — direction discriminator + DCA skip). All in-scope acceptance criteria from the issue body are satisfied in production code today:

AC item Where
Drop if not payment.is_in: return early-out tasks.py:_handle_payment — branches on payment.is_in both ways
is_lightning_inbound / is_lightning_outbound named variables tasks.py:98-99 (with comment block explaining the protocol/business direction trap per the feedback_naming_business_vs_protocol memory)
Cash-in tx_type="cash_in" populated on dca_settlements flowing through bitspire.parse_settlement + CreateDcaSettlementData
Outbound discriminator (source=bitspire check) tasks.py:108-109 (skip silently for non-bitspire outbounds)
Direction-aware cross-check (cash_out ↔ inbound, cash_in ↔ outbound) tasks.py:149-160 (records rejected row on mismatch)
Direction discriminator persisted as source of truth dca_settlements.tx_type column

Operator UI surfaces tx_type per-row as a chip on the settlements table (commit ecf432c feat(v2)(ui): tx_type chip in operator settlements table (S8 UI)).

Carryover follow-ups (separate trackers)

These were listed in the body's "open questions" but aren't AC items here — they belong on their own issues:

  • Stale-LNURL refund queue + worklist surface — the original design says it lives in aiolabs/satmachineadmin#3 (abandoned/partial-tx queue). When #3 is taken up, fold this in.
  • aiolabs/lamassu-next#44 upstream metadata gap — separately tracked; affects fallback-split UX surfaced in aiolabs/satmachineadmin#25.
  • NIP-57-style cash-in receipt symmetry with cash-out — folds into aiolabs/satmachineadmin#17 (S3) when that ships.
  • LNURL-withdraw TTL matching the kind-21000 session TTL — incident-driven AC item from the body; not yet wired. Worth filing as a small follow-up if the operator-flow hasn't already exercised it. Defer to whether the v1.1 joint smoke surfaces the stale-LNURL behaviour before re-filing.

Closing as shipped.

## Shipped — closing Cash-in path landed on `v2-bitspire` at commit `eca6e96` (`feat(v2): wire cash-in routing — direction discriminator + DCA skip`). All in-scope acceptance criteria from the issue body are satisfied in production code today: | AC item | Where | |---|---| | Drop `if not payment.is_in: return` early-out | `tasks.py:_handle_payment` — branches on `payment.is_in` both ways | | `is_lightning_inbound` / `is_lightning_outbound` named variables | `tasks.py:98-99` (with comment block explaining the protocol/business direction trap per the `feedback_naming_business_vs_protocol` memory) | | Cash-in `tx_type="cash_in"` populated on `dca_settlements` | flowing through `bitspire.parse_settlement` + `CreateDcaSettlementData` | | Outbound discriminator (`source=bitspire` check) | `tasks.py:108-109` (skip silently for non-bitspire outbounds) | | Direction-aware cross-check (`cash_out ↔ inbound`, `cash_in ↔ outbound`) | `tasks.py:149-160` (records rejected row on mismatch) | | Direction discriminator persisted as source of truth | `dca_settlements.tx_type` column | Operator UI surfaces tx_type per-row as a chip on the settlements table (commit `ecf432c feat(v2)(ui): tx_type chip in operator settlements table (S8 UI)`). ### Carryover follow-ups (separate trackers) These were listed in the body's "open questions" but aren't AC items here — they belong on their own issues: - **Stale-LNURL refund queue + worklist surface** — the original design says it lives in `aiolabs/satmachineadmin#3` (abandoned/partial-tx queue). When #3 is taken up, fold this in. - **`aiolabs/lamassu-next#44`** upstream metadata gap — separately tracked; affects fallback-split UX surfaced in `aiolabs/satmachineadmin#25`. - **NIP-57-style cash-in receipt symmetry** with cash-out — folds into `aiolabs/satmachineadmin#17` (S3) when that ships. - **LNURL-withdraw TTL matching the kind-21000 session TTL** — incident-driven AC item from the body; not yet wired. Worth filing as a small follow-up if the operator-flow hasn't already exercised it. Defer to whether the v1.1 joint smoke surfaces the stale-LNURL behaviour before re-filing. Closing as shipped.
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/satmachineadmin#22
No description provided.