fix: prevent TOCTOU race condition in UTXO locking (issue #125)#129
fix: prevent TOCTOU race condition in UTXO locking (issue #125)#129idan57570idan-svg wants to merge 1 commit into
Conversation
…t#125) Move BEGIN IMMEDIATE transaction to before the idempotency check and UTXO selection, ensuring all three operations are atomic. Previously, the transaction only covered the write phase, leaving a race window where two concurrent calls could both pass the idempotency check, select the same UTXOs as unspent, and create conflicting pending transactions. Also fix lock_output to return OutputAlreadyLocked when the UPDATE affects 0 rows (UTXO already locked), instead of silently returning Ok(). This provides defense-in-depth and ensures callers receive a clear error rather than a silent false success. Fixes tari-project#125 (recreation of tari-project#41)
There was a problem hiding this comment.
Code Review
This pull request introduces a new OutputAlreadyLocked error variant and updates lock_output to return this error when no rows are affected. It also wraps the idempotency check and UTXO selection in FundLocker within an immediate transaction to prevent TOCTOU races. The review feedback suggests optimizing this transaction logic using a double-checked locking pattern to reduce database lock contention on duplicate requests, and simplifying the transaction references by leveraging Rust's deref coercion.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // BEGIN IMMEDIATE acquires a write-reservation lock before any reads, ensuring the | ||
| // idempotency check, UTXO selection, and locking are fully atomic. Without this, | ||
| // two concurrent calls can both pass the idempotency check, select the same UTXOs as | ||
| // unspent, and create conflicting pending transactions (TOCTOU race, issue #125). | ||
| let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
|
|
||
| if let Some(idempotency_key_str) = &idempotency_key | ||
| && let Some(response) = | ||
| db::find_pending_transaction_locked_funds_by_idempotency_key(&conn, idempotency_key_str, account_id)? | ||
| db::find_pending_transaction_locked_funds_by_idempotency_key(&*transaction, idempotency_key_str, account_id)? | ||
| { | ||
| info!( | ||
| target: "audit", | ||
| idempotency_key = idempotency_key_str.as_str(); | ||
| "Found existing pending transaction lock" | ||
| ); | ||
| return Ok(response); | ||
| // `transaction` drops here → implicit ROLLBACK (no writes made, safe) | ||
| } |
There was a problem hiding this comment.
Acquiring an IMMEDIATE transaction lock immediately on every call to lock can cause unnecessary database lock contention, especially when serving duplicate/retried requests that are resolved by the idempotency check.
We can optimize this by using a double-checked locking pattern:
- Perform a quick, lock-free pre-check using the shared connection (
&conn) to see if the idempotency key already exists. If found, return the response immediately without acquiring any write locks. - If not found, acquire the
IMMEDIATEtransaction lock and perform the check again inside the transaction to safely handle concurrent races.
Additionally, we can simplify &*transaction to &transaction as Rust's deref coercion automatically handles the conversion from &Transaction to &Connection.
if let Some(idempotency_key_str) = &idempotency_key
&& let Some(response) =
db::find_pending_transaction_locked_funds_by_idempotency_key(&conn, idempotency_key_str, account_id)?
{
info!(
target: "audit",
idempotency_key = idempotency_key_str.as_str();
"Found existing pending transaction lock (pre-check)"
);
return Ok(response);
}
// BEGIN IMMEDIATE acquires a write-reservation lock before any reads, ensuring the
// idempotency check, UTXO selection, and locking are fully atomic. Without this,
// two concurrent calls can both pass the idempotency check, select the same UTXOs as
// unspent, and create conflicting pending transactions (TOCTOU race, issue #125).
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
if let Some(idempotency_key_str) = &idempotency_key
&& let Some(response) =
db::find_pending_transaction_locked_funds_by_idempotency_key(&transaction, idempotency_key_str, account_id)?
{
info!(
target: "audit",
idempotency_key = idempotency_key_str.as_str();
"Found existing pending transaction lock"
);
return Ok(response);
}| let utxo_selection = | ||
| input_selector.fetch_unspent_outputs(&conn, amount, num_outputs, fee_per_gram, estimated_output_size)?; | ||
| input_selector.fetch_unspent_outputs(&*transaction, amount, num_outputs, fee_per_gram, estimated_output_size)?; |
There was a problem hiding this comment.
Simplify &*transaction to &transaction. Rust's deref coercion automatically converts &Transaction to &Connection because Transaction implements Deref<Target = Connection>.
| let utxo_selection = | |
| input_selector.fetch_unspent_outputs(&conn, amount, num_outputs, fee_per_gram, estimated_output_size)?; | |
| input_selector.fetch_unspent_outputs(&*transaction, amount, num_outputs, fee_per_gram, estimated_output_size)?; | |
| let utxo_selection = | |
| input_selector.fetch_unspent_outputs(&transaction, amount, num_outputs, fee_per_gram, estimated_output_size)?; |
Summary
Fixes the TOCTOU race condition in FundLocker::lock() described in #125.
Root cause: The idempotency check and UTXO selection ran outside any database transaction. Two concurrent calls could both pass the idempotency check, select the same UTXOs as 'Unspent', and produce conflicting pending transactions.
Additionally, lock_output() silently returned Ok(()) when the UPDATE matched 0 rows (UTXO already locked by a concurrent request), causing callers to receive a LockFundsResult indicating successful locking for UTXOs they do not actually hold.
Changes
**und_locker.rs** — Move BEGIN IMMEDIATE transaction to before the idempotency check:
db/outputs.rs — Check rows_affected in lock_output:
db/error.rs — Add \OutputAlreadyLocked { output_id: i64 }\ variant.
Why BEGIN IMMEDIATE
\conn.transaction()\ defaults to \BEGIN DEFERRED, which only acquires a write lock when the first write operation happens. This leaves a race window between the read phase (idempotency check + UTXO selection) and the write phase (create_pending_tx + lock_output).
\BEGIN IMMEDIATE\ acquires a reserved lock immediately, serializing all concurrent \lock()\ calls through the critical section. Since \FundLocker::lock()\ is the only write path for UTXO locking, this provides complete protection without excessive lock contention.
Fixes #125 (recreation of #41)