Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions node/test_utxo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,26 @@ def test_double_spend_rejected(self):
self.assertEqual(self.db.get_balance('bob'), 100 * UNIT)
self.assertEqual(self.db.get_balance('eve'), 0)

def test_spend_box_double_spend_raises(self):
"""spend_box() must raise ValueError on double-spend, not silently
return the box dict (bounty #2819 HIGH-1 TOCTOU fix)."""
self._apply_coinbase('alice', 100 * UNIT)
boxes = self.db.get_unspent_for_address('alice')
box_id = boxes[0]['box_id']

# First spend succeeds
result = self.db.spend_box(box_id, 'tx_first')
self.assertIsNotNone(result)

# Second spend must raise, not return silently
with self.assertRaises(ValueError):
self.db.spend_box(box_id, 'tx_second')

def test_spend_box_nonexistent_returns_none(self):
"""spend_box() on a nonexistent box_id returns None."""
result = self.db.spend_box('deadbeef' * 8, 'tx_whatever')
self.assertIsNone(result)

def test_nonexistent_input_rejected(self):
ok = self.db.apply_transaction({
'tx_type': 'transfer',
Expand Down
33 changes: 31 additions & 2 deletions node/utxo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,34 +207,63 @@ def spend_box(self, box_id: str, spent_by_tx: str,
"""
Mark a box as spent. Returns the box dict or None if not found.
Raises ValueError on double-spend attempt.

When called without an external ``conn``, acquires BEGIN IMMEDIATE
to prevent TOCTOU races between the SELECT and UPDATE.
"""
own = conn is None
if own:
conn = self._conn()
try:
if own:
conn.execute("BEGIN IMMEDIATE")

row = conn.execute(
"SELECT * FROM utxo_boxes WHERE box_id = ?", (box_id,)
).fetchone()
if not row:
if own:
conn.execute("ROLLBACK")
return None
if row['spent_at'] is not None:
if own:
conn.execute("ROLLBACK")
raise ValueError(
f"Double-spend attempt: box {box_id[:16]} already spent "
f"by tx {row['spent_by_tx'][:16]}"
)
conn.execute(
updated = conn.execute(
"""UPDATE utxo_boxes
SET spent_at = ?, spent_by_tx = ?
WHERE box_id = ? AND spent_at IS NULL""",
(int(time.time()), spent_by_tx, box_id),
)
).rowcount
if updated != 1:
# Another connection spent this box between our SELECT
# and UPDATE — treat as double-spend.
if own:
conn.execute("ROLLBACK")
raise ValueError(
f"Double-spend race: box {box_id[:16]} was spent "
f"concurrently"
)
if own:
conn.commit()
return dict(row)
except ValueError:
raise
except Exception:
if own:
try:
conn.execute("ROLLBACK")
except Exception:
pass
raise
finally:
if own:
conn.close()


def get_box(self, box_id: str) -> Optional[dict]:
"""Get a box by ID (spent or unspent)."""
conn = self._conn()
Expand Down
Loading