feat(v2): wire cash-in routing — direction discriminator + DCA skip

Structural half of S8 (aiolabs/satmachineadmin#22). Listener now
accepts BOTH inbound and outbound payments instead of filtering on
`is_in=True`; distribution gates the DCA leg on tx_type so the
liquidity-flow direction at the ATM drives behaviour, not the
Lightning protocol direction at the operator's wallet.

tasks.py:
 - Drop the `if not payment.is_in` pre-filter; keep `payment.success`.
 - Pair-name the two axes (`is_lightning_inbound`/`_outbound` for
   protocol vs `tx_type ∈ {cash_out, cash_in}` for business) per
   the naming-inversion memory.
 - Outbound payments need `extra.source == "bitspire"` before we
   touch them — without it we can't tell the operator paying their
   landlord from a cash-in settlement; skip silently.
 - Cross-axis sanity gate: refuse to process when protocol direction
   disagrees with business direction (cash_out must be inbound,
   cash_in must be outbound). Catches a buggy/malicious upstream
   stamping `type=cash_out` on an outbound payment.

distribution.py:
 - Gate `_pay_dca_distributions` on `tx_type == "cash_out"`. Cash-in
   liquidity stays in the operator's wallet — there's no LP share to
   distribute. Skipped leg is written as an audit row via
   `_record_skipped_leg` so the dashboard surfaces "DCA intentionally
   skipped" instead of a phantom missing leg.

Still pending in S8: the UI marker (cash_in tx_type chip in the
operator settlements table) and end-to-end test against a real
LNURL-withdraw redemption.

Tests: 75 passed (no regression vs prior green state; `test_router`
remains a pre-existing pytest-asyncio plugin issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 23:21:30 +02:00
commit eca6e961b7
2 changed files with 71 additions and 2 deletions

View file

@ -391,7 +391,28 @@ async def process_settlement(settlement_id: str) -> None:
try:
await _pay_super_fee(settlement, machine, super_config, errors)
await _pay_operator_splits(settlement, machine, errors)
await _pay_dca_distributions(settlement, machine, errors)
# DCA distribution: applies to cash_out (LPs share the principal
# the customer paid into BTC). Does NOT apply to cash_in — that
# flow is liquidity coming IN to the operator's wallet, not
# going OUT to LPs. Skip with an audit row so the operator
# dashboard surfaces "DCA intentionally skipped for cash_in
# settlement" rather than displaying a phantom missing leg.
# See aiolabs/satmachineadmin#22 (S8 — wire cash-in path).
if settlement.tx_type == "cash_out":
await _pay_dca_distributions(settlement, machine, errors)
else:
await _record_skipped_leg(
settlement,
machine,
leg_type="dca",
amount_sats=settlement.principal_sats,
reason=(
f"DCA distribution does not apply to tx_type="
f"{settlement.tx_type!r}; principal stays in the "
"operator's wallet as liquidity received from the "
"cash-in customer."
),
)
except Exception as exc: # last-resort guard
logger.exception("distribution: unexpected error processing settlement")
errors.append(f"unexpected: {exc}")