Unique multi-use links can be over-withdrawn: usescsv and used are whole-row read-modify-writes with no serialisation across sub-links #4

Open
opened 2026-10-09 16:47:22 +00:00 by padreug · 0 comments
Owner

The create_hash_check mutex in api_lnurl_callback (views_lnurl.py:135) is keyed on id_unique_hash or unique_hash. For is_unique links each sub-link gets its own key, so two claims on different sub-links of the same link run concurrently. Both then mutate link-wide fields from their own stale snapshot: remove_unique_withdraw_link (crud.py:98-105) recomputes usescsv in memory and update_withdraw_link writes the whole row; increment_withdraw_link (crud.py:108-111) does link.used = link.used + 1 and writes the whole row again. Interleaving resurrects an already-spent sub-link in usescsv and under-counts used, so real payouts continue past uses. These are the links bitSpire mints for cash-in payouts.

Fix direction: funnel claim-time allowance mutation through one crud primitive that runs read -> check -> UPDATE ... SET used = used + 1, usescsv = :rest WHERE id = :id AND used < uses inside a single db.connect() block (the extension's asyncio DB lock serialises it on both SQLite and Postgres), treating 0 rows affected as spent. Sandbox PR #11 (reserve_withdraw_link_use, tests/test_allowance.py with a 20-way concurrency test) implements this and can be ported as-is since the fork is still at the same commit.

Found during reforge run #1 (sandbox withdraw#2).

The `create_hash_check` mutex in `api_lnurl_callback` (`views_lnurl.py:135`) is keyed on `id_unique_hash or unique_hash`. For `is_unique` links each sub-link gets its own key, so two claims on different sub-links of the same link run concurrently. Both then mutate link-wide fields from their own stale snapshot: `remove_unique_withdraw_link` (`crud.py:98-105`) recomputes `usescsv` in memory and `update_withdraw_link` writes the whole row; `increment_withdraw_link` (`crud.py:108-111`) does `link.used = link.used + 1` and writes the whole row again. Interleaving resurrects an already-spent sub-link in `usescsv` and under-counts `used`, so real payouts continue past `uses`. These are the links bitSpire mints for cash-in payouts. Fix direction: funnel claim-time allowance mutation through one crud primitive that runs read -> check -> `UPDATE ... SET used = used + 1, usescsv = :rest WHERE id = :id AND used < uses` inside a single `db.connect()` block (the extension's asyncio DB lock serialises it on both SQLite and Postgres), treating 0 rows affected as spent. Sandbox PR #11 (`reserve_withdraw_link_use`, `tests/test_allowance.py` with a 20-way concurrency test) implements this and can be ported as-is since the fork is still at the same commit. Found during reforge run #1 (sandbox withdraw#2).
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/withdraw#4
No description provided.