|
| 1 | +"""Regression: escrow release/refund must touch only the SELECTED escrow row. |
| 2 | +
|
| 3 | +A parent_task_id can carry 2+ active escrow rows (escrow_credits_for_task -> |
| 4 | +escrow:{task} and reserve_swarm_dispatch_budget -> escrow:{reason} both stamp the |
| 5 | +same parent_task_id). The release/refund SELECTs one row (LIMIT 1) to size the |
| 6 | +payout but historically UPDATEd `WHERE parent_task_id=? AND status='active'`, |
| 7 | +mutating EVERY active row — silently losing credits from the closed-loop ledger. |
| 8 | +""" |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import unittest |
| 12 | + |
| 13 | +from core.credit_ledger import ( |
| 14 | + refund_escrow_remainder, |
| 15 | + release_escrow_to_helper, |
| 16 | +) |
| 17 | +from storage.db import get_connection |
| 18 | +from storage.migrations import run_migrations |
| 19 | + |
| 20 | +_TS = "2026-06-22T00:00:00Z" |
| 21 | + |
| 22 | + |
| 23 | +def _insert_escrow(escrow_id: str, parent_task_id: str, escrowed: float) -> None: |
| 24 | + conn = get_connection() |
| 25 | + try: |
| 26 | + conn.execute( |
| 27 | + """INSERT INTO dispatch_credit_escrow |
| 28 | + (escrow_id, parent_task_id, poster_peer_id, total_escrowed, |
| 29 | + total_released, total_refunded, status, created_at, updated_at) |
| 30 | + VALUES (?, ?, ?, ?, 0, 0, 'active', ?, ?)""", |
| 31 | + (escrow_id, parent_task_id, "poster", escrowed, _TS, _TS), |
| 32 | + ) |
| 33 | + conn.commit() |
| 34 | + finally: |
| 35 | + conn.close() |
| 36 | + |
| 37 | + |
| 38 | +def _rows(parent_task_id: str): |
| 39 | + conn = get_connection() |
| 40 | + try: |
| 41 | + return { |
| 42 | + r["escrow_id"]: (float(r["total_released"]), float(r["total_refunded"]), r["status"]) |
| 43 | + for r in conn.execute( |
| 44 | + "SELECT escrow_id, total_released, total_refunded, status " |
| 45 | + "FROM dispatch_credit_escrow WHERE parent_task_id = ?", |
| 46 | + (parent_task_id,), |
| 47 | + ).fetchall() |
| 48 | + } |
| 49 | + finally: |
| 50 | + conn.close() |
| 51 | + |
| 52 | + |
| 53 | +class EscrowRowScopingTests(unittest.TestCase): |
| 54 | + def setUp(self) -> None: |
| 55 | + run_migrations() |
| 56 | + conn = get_connection() |
| 57 | + try: |
| 58 | + conn.execute("DELETE FROM dispatch_credit_escrow") |
| 59 | + conn.execute("DELETE FROM compute_credit_ledger") |
| 60 | + conn.commit() |
| 61 | + finally: |
| 62 | + conn.close() |
| 63 | + |
| 64 | + def test_release_touches_only_one_escrow_row(self) -> None: |
| 65 | + _insert_escrow("escrow:taskX", "taskX", 10.0) |
| 66 | + _insert_escrow("escrow:dispatchY", "taskX", 20.0) |
| 67 | + self.assertTrue(release_escrow_to_helper("taskX", "helper", 5.0)) |
| 68 | + rows = _rows("taskX") |
| 69 | + released = sorted(r[0] for r in rows.values()) |
| 70 | + # exactly one row released 5; the other untouched — NOT both (which would |
| 71 | + # over-release 10 against a 5 payout and corrupt the ledger). |
| 72 | + self.assertEqual(released, [0.0, 5.0]) |
| 73 | + |
| 74 | + def test_refund_settles_only_one_escrow_row(self) -> None: |
| 75 | + _insert_escrow("escrow:taskX", "taskX", 10.0) |
| 76 | + _insert_escrow("escrow:dispatchY", "taskX", 20.0) |
| 77 | + refunded = refund_escrow_remainder("taskX") |
| 78 | + self.assertGreater(refunded, 0.0) |
| 79 | + rows = _rows("taskX") |
| 80 | + statuses = sorted(r[2] for r in rows.values()) |
| 81 | + # one row settled, the other still active — the second escrow is NOT |
| 82 | + # closed-and-zeroed without being paid out. |
| 83 | + self.assertEqual(statuses, ["active", "settled"]) |
| 84 | + |
| 85 | + |
| 86 | +if __name__ == "__main__": |
| 87 | + unittest.main() |
0 commit comments