Skip to content

fix(utxo): remove TOCTOU race in spend_box() (#6345)#6349

Open
crowniteto wants to merge 1 commit into
Scottcjn:mainfrom
crowniteto:fix/utxo-toctou-spend-box-6345
Open

fix(utxo): remove TOCTOU race in spend_box() (#6345)#6349
crowniteto wants to merge 1 commit into
Scottcjn:mainfrom
crowniteto:fix/utxo-toctou-spend-box-6345

Conversation

@crowniteto
Copy link
Copy Markdown

Fix for #6345 — TOCTOU Race Condition in spend_box()

Problem

The spend_box() method had a SELECT-then-UPDATE pattern that created a TOCTOU window. Two concurrent transactions could both observe spent_at IS NULL in the SELECT before either reached the UPDATE, leading to a double-spend race condition.

Root Cause

The SELECT check at lines 290-303 was redundant — the subsequent UPDATE already uses WHERE spent_at IS NULL, which atomically prevents double-spends. The SELECT only added a race window.

Fix

  • Removed the redundant SELECT before the UPDATE
  • The atomic UPDATE with WHERE spent_at IS NULL now runs first
  • On failure (updated != 1), a follow-up SELECT distinguishes "not found" from "already spent" for better error messages
  • The return value now fetches the row after the UPDATE instead of using the stale pre-SELECT row

Testing

  • Syntax validated (ast.parse passes)
  • The fix maintains the same public API: Optional[dict] return, ValueError on double-spend
  • BEGIN IMMEDIATE + atomic UPDATE is stronger serialization than SELECT + UPDATE

Impact

…LECT before atomic UPDATE (Scottcjn#6345)

The SELECT-then-UPDATE pattern in spend_box() created a TOCTOU window:
two concurrent transactions could both observe spent_at IS NULL before
either reached the UPDATE, leading to a double-spend race.

Fix: remove the redundant SELECT check. The atomic UPDATE with
WHERE spent_at IS NULL is sufficient to serialize access. On failure
(updated != 1), a follow-up SELECT distinguishes 'not found' from
'already spent' for better error messages.

Fixes Scottcjn#6345
@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines labels May 26, 2026
Copy link
Copy Markdown
Contributor

@CyberNomad2000 CyberNomad2000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for a behavior regression in UtxoDB.spend_box().

The PR keeps the atomic UPDATE ... WHERE spent_at IS NULL, which is the right direction, but the own-connection failure path now rolls back before it checks whether the box exists. Because check is forced to None when own is true, a normal second call to spend_box() on an already-spent box returns None instead of raising ValueError as the method contract and prior behavior require.

I verified this on head 06aca1cc9713e88fc5f3f6acedceb36206f7571b:

python -m py_compile node/utxo_db.py
# pass

first_spend_returned True spent_by_tx 22222222
second_spend_returned None

That second result should be a double-spend ValueError, not the same result as a missing box. A minimal fix is to do the already-spent lookup before rolling back/closing the own connection, or otherwise preserve the previous not-found vs already-spent distinction after the failed atomic update.

Copy link
Copy Markdown
Contributor

@jaxint jaxint left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Thanks for contributing to RustChain! 🦀

Copy link
Copy Markdown

@MolhamHamwi MolhamHamwi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed node/utxo_db.py in this PR, focusing on UTXODatabase.spend_box() and the atomic UPDATE ... WHERE spent_at IS NULL change.

Two specific observations:

  1. The switch to an atomic conditional UPDATE is the right concurrency shape for issue #6345: it removes the pre-update SELECT window where two callers could both observe an unspent box before either writes spent_at.

  2. There is a behavior regression in the new updated != 1 branch when spend_box() owns the connection. The code rolls back, then sets check to None for own == True, so an already-spent box now returns None instead of raising ValueError with the previous double-spend message. That can make a double-spend attempt indistinguishable from a missing box for the common no-external-connection path. A regression test for “existing box with spent_at already set raises ValueError” would catch this.

I received RTC compensation for this review.

Copy link
Copy Markdown
Contributor

@jaxint jaxint left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Thanks for contributing to RustChain! 🦀

Copy link
Copy Markdown

@shadow88sky shadow88sky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused UTXO race fix. The core direction is right: replacing the pre-check SELECT with an atomic UPDATE ... WHERE spent_at IS NULL is the correct shape for closing the TOCTOU window in spend_box().

I need to request changes because the patch regresses the existing double-spend contract for the normal own-connection call path. When updated != 1, the code rolls back and then sets check to None whenever own is true:

if own:
    conn.execute("ROLLBACK")
check = conn.execute(...).fetchone() if not own else None

That means calling spend_box() without an external connection now returns None for an already-spent existing box instead of raising ValueError, making an already-spent box indistinguishable from a nonexistent box. The docstring still says double-spend attempts raise ValueError, and the existing unit test catches this regression.

Validation I ran:

  • .venv/bin/python -m py_compile node/utxo_db.py -> passes
  • git diff --check $(git rev-list --max-parents=0 HEAD)..HEAD -> passes
  • PYTHONPATH=node PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest node/test_utxo_db.py -q -k "spend_box_double_spend_raises or spend_box_nonexistent_returns_none" -> fails: test_spend_box_double_spend_raises does not raise ValueError; nonexistent-box behavior still passes

Suggested fix: preserve the atomic conditional update, but distinguish already-spent vs nonexistent before closing/rolling back the own connection, or perform a read-only follow-up query after rollback on a usable connection so the own-connection path keeps raising ValueError for existing spent boxes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants