fix(v2): refuse /retry when any leg already completed (double-pay guard)
Caught while answering the user's question about retry behaviour.
The /retry endpoint previously voided FAILED legs and flipped the
settlement back to 'pending', which then re-ran process_settlement.
But process_settlement re-creates every leg from scratch (super_fee +
operator_split + dca legs); it doesn't dedupe against already-completed
ones. So if a previous distribution attempt completed some legs and
failed others (status='errored' with mixed leg outcomes), hitting
/retry would re-pay every successful leg — actually double-paying real
sats.
Fix: refuse /retry with 400 when count_completed_legs_for_settlement > 0.
The error message tells the operator their options:
- Edit the commission_splits ruleset to remove already-paid targets
before retrying
- Or pay the missing legs out-of-band
For the all-failed case (no completed legs), /retry continues to work
as before — all-or-nothing retry is safe.
This mirrors the existing partial-dispense guard
(distribution.apply_partial_dispense_and_redistribute) which refuses
when any leg has completed for the same reason (Lightning sats can't
be clawed back).
Splitpayments doesn't have this concern because each split is a
separate one-off payment with no retry semantics — they just log and
move on. Our model has an explicit retry but needs the symmetric
double-pay guard.
Future enhancement (post-v1): make process_settlement leg-aware so
it skips already-completed (settlement_id, leg_type, target) tuples
on re-run. Would let /retry handle partial-success cases too. Tracked
informally as an open thread; not on the omnibus issue yet.
76/76 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5de9cd5205
commit
a86f8dc25d
1 changed files with 20 additions and 1 deletions
21
views_api.py
21
views_api.py
|
|
@ -14,6 +14,7 @@ from lnbits.decorators import check_super_user, check_user_exists
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
append_settlement_note,
|
append_settlement_note,
|
||||||
|
count_completed_legs_for_settlement,
|
||||||
create_dca_client,
|
create_dca_client,
|
||||||
create_deposit,
|
create_deposit,
|
||||||
create_machine,
|
create_machine,
|
||||||
|
|
@ -573,7 +574,16 @@ async def api_retry_settlement(
|
||||||
Voids any failed legs (completed legs are NEVER re-paid — Lightning
|
Voids any failed legs (completed legs are NEVER re-paid — Lightning
|
||||||
sats already moved) and flips status 'errored' → 'pending', then
|
sats already moved) and flips status 'errored' → 'pending', then
|
||||||
re-invokes process_settlement. The optimistic-lock claim guards
|
re-invokes process_settlement. The optimistic-lock claim guards
|
||||||
against a concurrent listener re-fire racing this retry."""
|
against a concurrent listener re-fire racing this retry.
|
||||||
|
|
||||||
|
REFUSES when any leg has already completed. Reason: process_settlement
|
||||||
|
re-creates every leg from scratch (super_fee + operator_split + dca);
|
||||||
|
if a previous attempt already completed some of them, retrying would
|
||||||
|
DOUBLE-PAY those legs. For partial-success failures, the operator
|
||||||
|
needs to either edit the commission_splits ruleset to remove the
|
||||||
|
already-paid targets before retry, or manually pay the missing legs
|
||||||
|
out-of-band.
|
||||||
|
"""
|
||||||
settlement = await get_settlement(settlement_id)
|
settlement = await get_settlement(settlement_id)
|
||||||
if settlement is None:
|
if settlement is None:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
|
||||||
|
|
@ -586,6 +596,15 @@ async def api_retry_settlement(
|
||||||
f"settlement status must be 'errored' to retry "
|
f"settlement status must be 'errored' to retry "
|
||||||
f"(currently '{settlement.status}')",
|
f"(currently '{settlement.status}')",
|
||||||
)
|
)
|
||||||
|
completed = await count_completed_legs_for_settlement(settlement_id)
|
||||||
|
if completed > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
f"refusing to retry: {completed} leg(s) already completed. "
|
||||||
|
"Re-running distribution would double-pay them. Edit the "
|
||||||
|
"commission_splits ruleset to remove the already-paid targets, "
|
||||||
|
"or manually pay the missing legs.",
|
||||||
|
)
|
||||||
updated = await reset_settlement_for_retry(settlement_id)
|
updated = await reset_settlement_for_retry(settlement_id)
|
||||||
if updated is None or updated.status != "pending":
|
if updated is None or updated.status != "pending":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue