fix(v2): decouple listener + skipped-leg audit (fix bundle 2)

Closes H4, H5, M8 from the v2-bitspire review (omnibus follow-up #11).

H4 — Decouple invoice listener from distribution.
  tasks._handle_payment now spawns process_settlement on a background
  task instead of awaiting it. The LNbits invoice queue is shared
  across every extension on the node; under load (a machine with 50
  LPs, a stalled internal payment, etc.) the previous synchronous path
  could freeze the queue for everyone. Concurrency is safe because
  fix bundle 1's claim_settlement_for_processing already prevents
  double-processing on listener re-fires.

  RUF006 fix: hold strong refs to in-flight tasks via a module-level
  set so the GC doesn't collect them mid-flight (asyncio.create_task
  only weakly references its task). Tasks self-clean via
  add_done_callback(set.discard).

H5 + M8 — Skipped-leg audit rows for stranded sats.
  Previously, four paths in distribution.py logged a warning and left
  sats in the machine wallet, marking the settlement 'processed' with
  no row-level visibility into where the un-paid sats sit:
    1. _pay_super_fee: super_fee_pct > 0 but super_fee_wallet_id unset
    2. _pay_operator_splits: no commission ruleset (default + override)
    3. _pay_dca_distributions: exchange_rate = 0 (fallback path)
    4. _pay_dca_distributions: no eligible LPs with positive balance
  Plus a fifth case the review didn't enumerate but is the same shape:
    5. _pay_dca_distributions: no flow-mode LPs at the machine at all

  Each now writes a dca_payments row with status='skipped', the
  intended leg_type (super_fee / operator_split / dca), the stranded
  amount in amount_sats, and a human-readable error_message explaining
  why. New _record_skipped_leg helper consolidates the pattern.

  This makes stranded sats visible in:
    - The machine detail dialog's settlements rows (the legs are
      filtered into the audit blob alongside completed/failed legs)
    - The payments CSV export
    - GET /api/v1/dca/payments?leg_type=...

  'skipped' is a documented leg-status value now (alongside pending /
  completed / failed / voided / refunded) — no schema change since
  status is TEXT.

Knock-on fix: void_open_legs_for_settlement (used by partial-dispense
recompute) now also includes status='skipped' in its WHERE clause so a
re-run doesn't double-count the audit rows from a prior attempt.

72/72 tests still pass. Lint clean.

Refs: aiolabs/satmachineadmin#11 — fix bundle 2 ✅
Remaining in #11: H6 (partial-dispense split ratio) + fix bundle 3
(dead-code purge) + the M and N items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:49:16 +02:00
commit ecef916dda
4 changed files with 118 additions and 28 deletions

View file

@ -69,6 +69,48 @@ def _payment_tag(machine: Machine) -> str:
return f"{PAYMENT_TAG_PREFIX}:{machine.machine_npub}"
async def _record_skipped_leg(
settlement: DcaSettlement,
machine: Machine,
leg_type: str,
amount_sats: int,
reason: str,
client_id: str | None = None,
) -> None:
"""Audit row for sats intentionally left in the machine wallet.
Distinct from 'failed' (which means pay_invoice errored). 'skipped' means
we never attempted the pay — by design, because some prerequisite was
missing (super wallet not configured, no operator ruleset, no exchange
rate, no eligible LPs). Operator sees these in payment history and on
the settlement detail blob; the audit trail explains where un-paid
sats are sitting.
"""
if amount_sats <= 0:
return
leg = await create_dca_payment(
CreateDcaPaymentData(
settlement_id=settlement.id,
client_id=client_id,
machine_id=machine.id,
operator_user_id=machine.operator_user_id,
leg_type=leg_type,
destination_wallet_id=None,
destination_ln_address=None,
amount_sats=amount_sats,
amount_fiat=None,
exchange_rate=None,
transaction_time=datetime.now(timezone.utc),
external_payment_hash=None,
)
)
await update_payment_status(leg.id, "skipped", None, reason[:512])
logger.info(
f"distribution: skipped {leg_type} leg "
f"({amount_sats} sats) — {reason}"
)
def _resolve_partial_dispense_gross(
settlement: DcaSettlement, data: PartialDispenseData
) -> int:
@ -361,11 +403,13 @@ async def _pay_super_fee(
return
if super_config is None or not super_config.super_fee_wallet_id:
# Super has configured a fee but not a destination wallet — leave
# the sats in the machine wallet and warn. The super needs to
# configure their wallet before they can collect.
logger.warning(
f"distribution: super_fee_sats={settlement.platform_fee_sats} "
f"left in machine wallet (super_fee_wallet_id not set)"
# the sats in the machine wallet and record a skipped audit row.
# The super needs to configure their wallet before they can collect.
await _record_skipped_leg(
settlement, machine,
leg_type="super_fee",
amount_sats=settlement.platform_fee_sats,
reason="super_fee_wallet_id not configured by LNbits super",
)
return
await _pay_internal(
@ -396,10 +440,14 @@ async def _pay_operator_splits(
machine.operator_user_id, machine.id
)
if not splits:
logger.warning(
f"distribution: operator_fee_sats={settlement.operator_fee_sats} "
f"left in machine wallet (operator has no commission_splits ruleset "
f"for machine {machine.id})"
await _record_skipped_leg(
settlement, machine,
leg_type="operator_split",
amount_sats=settlement.operator_fee_sats,
reason=(
"operator has no commission_splits ruleset for this machine "
"(neither per-machine override nor operator default)"
),
)
return
# Pure allocator handles the rounding rule (last leg absorbs remainder).
@ -443,14 +491,25 @@ async def _pay_dca_distributions(
# Fallback path with no exchange rate (bitSpire Payment.extra absent).
# Without a rate we can't compute fiat balances → can't compute
# proportional shares → leave net_sats in the machine wallet for
# the operator to manually reconcile.
logger.warning(
f"distribution: net_sats={settlement.net_sats} left in machine "
f"wallet (no exchange_rate; fallback path; see lamassu-next#44)"
# manual reconciliation. Audit row makes the strand visible.
await _record_skipped_leg(
settlement, machine,
leg_type="dca",
amount_sats=settlement.net_sats,
reason=(
"no exchange_rate on settlement (bitSpire fallback path; "
"see aiolabs/lamassu-next#44)"
),
)
return
clients = await get_flow_mode_clients_for_machine(machine.id)
if not clients:
await _record_skipped_leg(
settlement, machine,
leg_type="dca",
amount_sats=settlement.net_sats,
reason="no active flow-mode LPs registered at this machine",
)
return
# Build {client_id: remaining_fiat_balance} for proportional allocation.
client_balances: dict[str, float] = {}
@ -460,6 +519,15 @@ async def _pay_dca_distributions(
continue
client_balances[client.id] = summary.remaining_balance
if not client_balances:
await _record_skipped_leg(
settlement, machine,
leg_type="dca",
amount_sats=settlement.net_sats,
reason=(
"no LP has remaining-fiat-balance > 0 — all confirmed deposits "
"already paid out"
),
)
return
# Compute proportional sat allocations, then cap each at the client's
# remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard).