Skip to content

Commit aca8e16

Browse files
authored
Merge pull request #29 from Parad0x-Labs/feat/nulla-escrow-multirow-fix-2026-06
fix(credit-ledger): escrow release/refund scope to escrow_id (critical money-loss)
2 parents a66e248 + 4696f96 commit aca8e16

2 files changed

Lines changed: 93 additions & 6 deletions

File tree

core/credit_ledger.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -801,9 +801,9 @@ def release_escrow_to_helper(
801801
"""
802802
UPDATE dispatch_credit_escrow
803803
SET total_released = total_released + ?, updated_at = ?
804-
WHERE parent_task_id = ? AND status = 'active'
804+
WHERE escrow_id = ?
805805
""",
806-
(actual_payout, now_iso, parent_task_id),
806+
(actual_payout, now_iso, escrow["escrow_id"]),
807807
)
808808
conn.commit()
809809
return True
@@ -838,8 +838,8 @@ def refund_escrow_remainder(parent_task_id: str) -> float:
838838
remaining = float(escrow["total_escrowed"]) - float(escrow["total_released"]) - float(escrow["total_refunded"])
839839
if remaining <= 0:
840840
conn.execute(
841-
"UPDATE dispatch_credit_escrow SET status = 'settled', updated_at = ? WHERE parent_task_id = ? AND status = 'active'",
842-
(now_iso, parent_task_id),
841+
"UPDATE dispatch_credit_escrow SET status = 'settled', updated_at = ? WHERE escrow_id = ?",
842+
(now_iso, escrow["escrow_id"]),
843843
)
844844
conn.commit()
845845
return 0.0
@@ -857,9 +857,9 @@ def refund_escrow_remainder(parent_task_id: str) -> float:
857857
"""
858858
UPDATE dispatch_credit_escrow
859859
SET total_refunded = total_refunded + ?, status = 'settled', updated_at = ?
860-
WHERE parent_task_id = ? AND status = 'active'
860+
WHERE escrow_id = ?
861861
""",
862-
(remaining, now_iso, parent_task_id),
862+
(remaining, now_iso, escrow["escrow_id"]),
863863
)
864864
conn.commit()
865865
return remaining

tests/test_escrow_row_scoping.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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

Comments
 (0)