Refunds can double-pay, and the public GET /api/v1/events/{id} cancels and refunds as a side effect #70

Open
opened 2026-10-09 17:17:41 +00:00 by padreug · 0 comments
Owner

refund_tickets (services.py:494-506) fires the LNURL payout and only afterwards sets ticket.extra.refunded = True. There is no claim before execute(), so two overlapping runs both see refunded == False and both pay. One of the triggers is api_get_event (views_api.py:310-315): an unauthenticated GET that, for a conditional event whose window closed under min_tickets, sets event.canceled = True, persists it, and calls refund_tickets. Any crawler, double-click or retry storm on the public event page can fan out concurrent refund loops; the organiser's wallet pays out N times per ticket.

Fix direction: add a refund_status column (none -> refunding -> refunded) and claim each ticket with a conditional UPDATE before paying, releasing the claim if the payout fails; make api_get_event read-only and move auto-cancel of underfunded, closed events to a scheduled task plus the owner-only api_event_cancel. Sandbox PR #14 (second commit) implements exactly this, including tests/test_refund_idempotency.py. Also worth folding in: purge_unpaid_tickets is still a DELETE executed from the same GET (views_api.py:281).

Found during reforge run #1 (sandbox events#4).

`refund_tickets` (`services.py:494-506`) fires the LNURL payout and only afterwards sets `ticket.extra.refunded = True`. There is no claim before `execute()`, so two overlapping runs both see `refunded == False` and both pay. One of the triggers is `api_get_event` (`views_api.py:310-315`): an unauthenticated GET that, for a conditional event whose window closed under `min_tickets`, sets `event.canceled = True`, persists it, and calls `refund_tickets`. Any crawler, double-click or retry storm on the public event page can fan out concurrent refund loops; the organiser's wallet pays out N times per ticket. Fix direction: add a `refund_status` column (`none -> refunding -> refunded`) and claim each ticket with a conditional UPDATE before paying, releasing the claim if the payout fails; make `api_get_event` read-only and move auto-cancel of underfunded, closed events to a scheduled task plus the owner-only `api_event_cancel`. Sandbox PR #14 (second commit) implements exactly this, including `tests/test_refund_idempotency.py`. Also worth folding in: `purge_unpaid_tickets` is still a DELETE executed from the same GET (`views_api.py:281`). Found during reforge run #1 (sandbox events#4).
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/events#70
No description provided.