S8 — Wire cash-in path (LNURL-withdraw outbound + naming hygiene) #22
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #13. Closes gap G10 (cash-in path is not wired).
Problem
tasks.py:_handle_paymentfiltersis_in=Trueonly (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 staledca_machinesrow) 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_inis a LNbits protocol direction ("this wallet received sats"), not a bitSpire business direction.payment.is_inon operator walletTrue(operator wallet receives BTC)False(operator wallet sends BTC via LNURL-withdraw)In this repo we will:
is_in/is_outto mean the business direction.tasks.pyconsumepayment.is_inasis_lightning_inbound; explicit second branch asis_lightning_outbound.dca_settlements.tx_type ∈ {"cash_in","cash_out"}is the source of truth for business direction.payment.is_inspelling out which business direction it catches.(Already captured as a feedback memory; this issue is where the convention lands in code.)
Changes
tasks.pyif not payment.is_in: returnearly-out.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".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:
extra.source = "bitspire"andextra.flow = "cash_in"on the LNURL-withdraw it generates (depends onaiolabs/lamassu-next#44).withdrawextension; the resulting withdraw row carries a knownextrapayload our listener can correlate.extra.source != "bitspire", skip — this was the operator using their own wallet for something unrelated.bitspire.parse_settlementdirectionarg. For cash-in: readgross_satsfrom the outboundpayment.sat, parsefiat_amount,fiat_code,exchange_rate,bills_insertedfromPayment.extra.distribution.pyalready 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
NIP-40expirationtag on the kind-21000 response + a matchingwebhook_url/expiryon the LNURL-withdraw row).Settlement attestation for cash-in (S3 follow-on)
["direction", "cash_in"]. Operator audit symmetry: every settlement (in either direction) has a public, preimage-anchored attestation.Open questions
aiolabs/satmachineadmin#3is the right home for this.)aiolabs/lamassu-next#44populatingextra.source = "bitspire"andextra.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
dca_settlementsrow withtx_type="cash_in".is_in/is_outsynonyms for business direction; all branches explicitly named.dca_settlementsrow.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.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_typefrom Payment.extra, NOTpayment.is_inThis issue's "Changes →
tasks.py" section reads:#23 proposed a stronger pattern:
The
tx_typediscriminator 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. Useis_inas 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-satsdcarow. This matches the existingstatus='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_typechip — 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
k1(or its hash) + payment preimage for cash-in symmetry.Suggested PR sequencing (carried from #23)
Two PRs, not one:
parse_settlementcash-in branch (no LNURL lifecycle changes). Ships the recording side; existing LNURL behaviour stays intact.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.
Shipped — closing
Cash-in path landed on
v2-bitspireat commiteca6e96(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:if not payment.is_in: returnearly-outtasks.py:_handle_payment— branches onpayment.is_inboth waysis_lightning_inbound/is_lightning_outboundnamed variablestasks.py:98-99(with comment block explaining the protocol/business direction trap per thefeedback_naming_business_vs_protocolmemory)tx_type="cash_in"populated ondca_settlementsbitspire.parse_settlement+CreateDcaSettlementDatasource=bitspirecheck)tasks.py:108-109(skip silently for non-bitspire outbounds)cash_out ↔ inbound,cash_in ↔ outbound)tasks.py:149-160(records rejected row on mismatch)dca_settlements.tx_typecolumnOperator 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:
aiolabs/satmachineadmin#3(abandoned/partial-tx queue). When #3 is taken up, fold this in.aiolabs/lamassu-next#44upstream metadata gap — separately tracked; affects fallback-split UX surfaced inaiolabs/satmachineadmin#25.aiolabs/satmachineadmin#17(S3) when that ships.Closing as shipped.