S8 — Wire cash‑in path (LNURL‑withdraw outbound) into settlements #23

Closed
opened 2026-05-15 18:24:33 +00:00 by padreug · 1 comment
Owner

Gap: G10 — cash‑in settlements are never recorded.

Today tasks.py:_handle_payment filters is_in=True only:

if not payment.is_in or not payment.success:
    return

Cash‑in (customer hands the ATM fiat in exchange for BTC) is implemented as
an LNURL‑withdraw the customer scans at the ATM — so the resulting
LNbits Payment is outbound from the operator's wallet
(is_in=False). The current listener drops it on the floor, so no
dca_settlements row is created and no super‑fee / DCA distribution
fires. From the operator's books, cash‑in does not exist.

This was confirmed during dev: a redemption from a different LNbits user
landed on greg's wallet (the operator), the payment row exists, but
satmachineadmin's "Settlements" tab for the machine stayed empty.

Naming hazard

Before extending the handler, rename the conditional so we stop conflating
two inverted axes:

Business direction (bitSpire) Lightning direction (LNbits Payment.is_in)
cash‑out (BTC→cash, customer pays invoice) True
cash‑in (cash→BTC, customer redeems LNURL‑withdraw) False

Payment.is_in is inbound at the operator's wallet on the Lightning
network
, which is the opposite of the bitSpire business direction.
Local variables in tasks.py / bitspire.py that read payment.is_in
should be aliased to is_lightning_inbound (and its counterpart
is_lightning_outbound wherever the opposite branch is named) — always
in pairs, because using one name without the other re‑introduces the
ambiguity the rename was meant to kill. Any branch that conditions on
payment.is_in should carry a one‑line docstring spelling out which
business direction it is catching.

Design

  1. 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).
  2. Routing. Same as cash‑out: get_active_machine_by_wallet_id(payment.wallet_id).
    For cash‑in the wallet is the operator's wallet (LNURL‑withdraw drains
    from it), so the same 1:1 machine↔wallet mapping holds — until S4
    (NIP‑78 fleet roster) takes over routing.
  3. bitspire.parse_settlement already accepts tx_type ∈ {cash_in, cash_out}. Verify it produces sane numbers for the cash‑in case
    (gross/net inversion, fee direction, ATM‑side vs operator‑side fee).
  4. Distribution legs. For cash‑in:
    • Super fee: still owed by the operator → same as cash‑out leg.
    • Commission split: business‑direction defined by ruleset; today's
      rulesets don't yet distinguish — pick a default (most likely "same
      split table applies") and document it. Open for product input.
    • DCA leg: does not apply to cash‑in (cash‑in is liquidity
      coming in, not going out to LPs). The leg should be skipped, not
      zero‑sized.
  5. LNURL‑withdraw lifecycle. Today's link can outlive the customer's
    physical session (real incident: ~7 h between issue and redemption,
    internet outage in between). Tie the LNURL TTL to the kind‑21000
    RPC's NIP‑40 expiration (#15 / S1) — same window, same trust story.
    On TTL expiry, the link must be revoked at LNbits so a late
    redemption fails closed instead of silently draining the wallet.
  6. Receipts (S3 / #17). The settlement attestation event should
    reference the LNURL k1 (or its hash) and the eventual payment
    preimage so cash‑in is auditable on the same footing as cash‑out.

Acceptance criteria

  • tasks.py listener no longer pre‑filters on is_in; processes
    cash_in and cash_out via tx_type discriminator.
  • All locals reading payment.is_in renamed to
    is_lightning_inbound / is_lightning_outbound (paired), with a
    docstring on each conditional branch.
  • bitspire.parse_settlement cash‑in branch verified end‑to‑end:
    gross/net/fee numbers match what bitSpire reported in
    Payment.extra.tx_type=cash_in.
  • distribution.process_settlement skips the DCA leg for cash_in
    (skip, not zero‑sized).
  • LNURL‑withdraw link is revoked at LNbits when the kind‑21000 RPC
    expires (S1 / #15 dependency — gate this acceptance criterion on #15
    landing).
  • UI: Settlements tab shows cash‑in rows with a distinct row marker
    (icon + tx_type chip) so operators don't have to read JSON to tell
    the two flows apart.
  • End‑to‑end smoke: real Sintra issues an LNURL‑withdraw; a second
    LNbits user redeems it; the operator's machine page shows a cash_in
    settlement row with correct sats and a fired super‑fee leg.

Out of scope

  • Cash‑in LP balance reconciliation (the inverse of DCA — LP deposits
    fiat to fund operator liquidity). Separate product workstream; this
    issue is just about recording the settlement.

References

  • Parent epic: #13 (Security pathway S0–S7). S8 is not a security
    hardening step — it is a functional gap surfaced by the security
    audit. Filed standalone so it can land independently and doesn't
    block S0/S1/S5.
  • Code refs:
    • ~/dev/shared/extensions/satmachineadmin/tasks.py:57 (the filter)
    • ~/dev/shared/extensions/satmachineadmin/bitspire.py:parse_settlement
    • ~/dev/shared/extensions/satmachineadmin/distribution.py:process_settlement

Sequencing

Can land independently of the security epic, but S1 (NIP‑40 expiration,
#15) is a hard dependency for the LNURL‑withdraw lifecycle piece — file
the listener + naming + parse_settlement work as a first PR; gate the
LNURL revocation PR on #15.

**Gap:** G10 — cash‑in settlements are never recorded. Today `tasks.py:_handle_payment` filters `is_in=True` only: ```python if not payment.is_in or not payment.success: return ``` Cash‑in (customer hands the ATM fiat in exchange for BTC) is implemented as an **LNURL‑withdraw** the customer scans at the ATM — so the resulting LNbits `Payment` is **outbound** from the operator's wallet (`is_in=False`). The current listener drops it on the floor, so no `dca_settlements` row is created and no super‑fee / DCA distribution fires. From the operator's books, cash‑in does not exist. This was confirmed during dev: a redemption from a different LNbits user landed on greg's wallet (the operator), the payment row exists, but satmachineadmin's "Settlements" tab for the machine stayed empty. ## Naming hazard Before extending the handler, rename the conditional so we stop conflating two inverted axes: | Business direction (bitSpire) | Lightning direction (LNbits `Payment.is_in`) | |---|---| | cash‑out (BTC→cash, customer pays invoice) | `True` | | cash‑in (cash→BTC, customer redeems LNURL‑withdraw) | `False` | `Payment.is_in` is **inbound at the operator's wallet on the Lightning network**, which is the *opposite* of the bitSpire business direction. Local variables in `tasks.py` / `bitspire.py` that read `payment.is_in` should be aliased to `is_lightning_inbound` (and its counterpart `is_lightning_outbound` wherever the opposite branch is named) — always in pairs, because using one name without the other re‑introduces the ambiguity the rename was meant to kill. Any branch that conditions on `payment.is_in` should carry a one‑line docstring spelling out which business direction it is catching. ## Design 1. **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`). 2. **Routing.** Same as cash‑out: `get_active_machine_by_wallet_id(payment.wallet_id)`. For cash‑in the wallet is the operator's wallet (LNURL‑withdraw drains from it), so the same 1:1 machine↔wallet mapping holds — until S4 (NIP‑78 fleet roster) takes over routing. 3. **`bitspire.parse_settlement`** already accepts `tx_type ∈ {cash_in, cash_out}`. Verify it produces sane numbers for the cash‑in case (gross/net inversion, fee direction, ATM‑side vs operator‑side fee). 4. **Distribution legs.** For cash‑in: - Super fee: still owed by the operator → same as cash‑out leg. - Commission split: business‑direction defined by ruleset; today's rulesets don't yet distinguish — pick a default (most likely "same split table applies") and document it. Open for product input. - DCA leg: **does not apply** to cash‑in (cash‑in is liquidity coming *in*, not going out to LPs). The leg should be skipped, not zero‑sized. 5. **LNURL‑withdraw lifecycle.** Today's link can outlive the customer's physical session (real incident: ~7 h between issue and redemption, internet outage in between). Tie the LNURL TTL to the kind‑21000 RPC's NIP‑40 expiration (#15 / S1) — same window, same trust story. On TTL expiry, the link must be revoked at LNbits so a late redemption fails closed instead of silently draining the wallet. 6. **Receipts (S3 / #17).** The settlement attestation event should reference the LNURL `k1` (or its hash) and the eventual payment preimage so cash‑in is auditable on the same footing as cash‑out. ## Acceptance criteria - [ ] `tasks.py` listener no longer pre‑filters on `is_in`; processes `cash_in` and `cash_out` via `tx_type` discriminator. - [ ] All locals reading `payment.is_in` renamed to `is_lightning_inbound` / `is_lightning_outbound` (paired), with a docstring on each conditional branch. - [ ] `bitspire.parse_settlement` cash‑in branch verified end‑to‑end: gross/net/fee numbers match what bitSpire reported in `Payment.extra.tx_type=cash_in`. - [ ] `distribution.process_settlement` skips the DCA leg for `cash_in` (skip, not zero‑sized). - [ ] LNURL‑withdraw link is revoked at LNbits when the kind‑21000 RPC expires (S1 / #15 dependency — gate this acceptance criterion on #15 landing). - [ ] UI: Settlements tab shows cash‑in rows with a distinct row marker (icon + tx_type chip) so operators don't have to read JSON to tell the two flows apart. - [ ] End‑to‑end smoke: real Sintra issues an LNURL‑withdraw; a second LNbits user redeems it; the operator's machine page shows a `cash_in` settlement row with correct sats and a fired super‑fee leg. ## Out of scope - Cash‑in LP balance reconciliation (the inverse of DCA — LP deposits fiat to fund operator liquidity). Separate product workstream; this issue is just about *recording* the settlement. ## References - Parent epic: #13 (Security pathway S0–S7). S8 is **not** a security hardening step — it is a functional gap surfaced *by* the security audit. Filed standalone so it can land independently and doesn't block S0/S1/S5. - Code refs: - `~/dev/shared/extensions/satmachineadmin/tasks.py:57` (the filter) - `~/dev/shared/extensions/satmachineadmin/bitspire.py:parse_settlement` - `~/dev/shared/extensions/satmachineadmin/distribution.py:process_settlement` ## Sequencing Can land independently of the security epic, but S1 (NIP‑40 expiration, #15) is a hard dependency for the LNURL‑withdraw lifecycle piece — file the listener + naming + parse_settlement work as a first PR; gate the LNURL revocation PR on #15.
Author
Owner

Closing as duplicate of #22. Both filed within ~10 minutes on 2026-05-15 with the same scope (G10 cash-in path + naming hygiene).

The epic body (#13) references #22 as the canonical S8 tracker, so consolidating there.

Content from this issue worth merging into #22:

  • LNURL-withdraw revocation gated on S1 (NIP-40) landing — S1 is now closed (#15). When #22 work starts, link revocation can be implemented directly.
  • Distribution-leg analysis for cash-in:
    • Super fee leg: still owed by operator → same as cash-out
    • Commission split: business-direction defined by ruleset, default = "same split table applies", document explicitly
    • DCA leg: skip (not zero-sized) for cash-in — liquidity flows in, not out to LPs
  • LNURL k1 (or its hash) + payment preimage referenced from the S3 receipt event for cash-in (per #17 once that lands)

Will fold these into #22's body when picking up S8 work. Until then they're preserved in this comment as a search hit.

Closing as **duplicate of #22**. Both filed within ~10 minutes on 2026-05-15 with the same scope (G10 cash-in path + naming hygiene). The epic body (#13) references #22 as the canonical S8 tracker, so consolidating there. **Content from this issue worth merging into #22:** - LNURL-withdraw revocation gated on S1 (NIP-40) landing — S1 is now closed (#15). When #22 work starts, link revocation can be implemented directly. - Distribution-leg analysis for cash-in: - Super fee leg: still owed by operator → same as cash-out - Commission split: business-direction defined by ruleset, default = "same split table applies", document explicitly - DCA leg: **skip** (not zero-sized) for cash-in — liquidity flows in, not out to LPs - LNURL `k1` (or its hash) + payment preimage referenced from the S3 receipt event for cash-in (per #17 once that lands) Will fold these into #22's body when picking up S8 work. Until then they're preserved in this comment as a search hit.
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#23
No description provided.