Summary
goat_tx_record rows of type InitWithdraw latch permanently on Processed. upsert_goat_tx_record refuses to overwrite an existing Processed status and pins a non-empty stored tx_hash, CancelWithdraw never resets the InitWithdraw row, and the only consumer scans Pending rows only. Result: after a user cancels a withdrawal and re-initiates it on the same graph, the operator node never queues a second KickoffReady, and the re-initiated withdrawal is stuck in local bookkeeping until manual DB repair.
Security impact: loss or lock-up of user withdrawal funds at the operator bookkeeping layer (freeze), plus incorrect state transition. No fund theft path identified.
Affected component
- Repository: GOATNetwork/bitvm-node
- Commit reviewed:
1ff9a4aeced45309a097eeba5955e26164302ab3 (current HEAD at time of review)
- Files:
crates/store/src/localdb.rs, node/src/scheduled_tasks/event_watch_task.rs, node/src/scheduled_tasks/graph_maintenance_tasks.rs
- Component class: Implementation (operator node withdraw bookkeeping)
Description
Three pieces combine:
- Sticky merge.
upsert_goat_tx_record (crates/store/src/localdb.rs:3569-3583) keeps the stored processing_status when the existing row is_processed() (exactly Processed, schema.rs:843-844) and keeps the stored non-empty tx_hash. A new Pending InitWithdraw for the same (instance_id, graph_id, tx_type) key therefore stays Processed with the old hash.
- Cancel does not reset. The
CancelWithdraw handler (node/src/scheduled_tasks/event_watch_task.rs:361-391) clears graph runtime fields and records a separate CancelWithdraw row with status Skipped under a different tx_type. It never resets the InitWithdraw row.
- Pending-only consumer.
get_user_init_withdraw_graphs (node/src/scheduled_tasks/graph_maintenance_tasks.rs:230-239) selects InitWithdraw rows with status Pending only, and detect_init_withdraw_call marks rows Processed via an unconditional UPDATE with no guard (graph_maintenance_tasks.rs:277-284, localdb.rs:3675-3686).
How it can be exploited (normal user flow, no privileges)
- User calls
initWithdraw. detect_init_withdraw_call enqueues KickoffReady and flips the row to Processed.
- User cancels.
CancelWithdraw leaves the InitWithdraw row Processed.
- User re-initiates on the same graph. The watcher upserts a new
Pending record with the new tx hash, but the sticky merge keeps Processed and the old hash.
- The
Pending-only scanner never returns that graph again, so no second KickoffReady is ever queued.
Recovery requires manual DB surgery; no self-healing path exists.
Steps to reproduce
- Watch
InitWithdraw for a graph; confirm detect_init_withdraw_call marks the goat_tx_record Processed and enqueues KickoffReady.
- Observe
CancelWithdraw for the same graph; confirm the InitWithdraw row stays Processed (event_watch_task.rs:361-391 never touches it).
- Observe a second
InitWithdraw for the same (instance_id, graph_id); confirm upsert_goat_tx_record keeps the Processed status and the old tx_hash (localdb.rs:3569-3583).
- Run
detect_init_withdraw_call again; get_user_init_withdraw_graphs returns nothing for that graph, so KickoffReady is never re-enqueued.
Proof of concept (source level)
No PoC transaction was performed and no live system was contacted; source-only review at 1ff9a4a:
// localdb.rs:3569-3583, the sticky merge
if goat_tx_record_store.is_processed() {
update_goat_tx_record.processing_status = goat_tx_record_store.processing_status;
}
...
if !goat_tx_record_store.tx_hash.is_empty() {
update_goat_tx_record.tx_hash = goat_tx_record_store.tx_hash.clone();
}
// event_watch_task.rs:361-375, cancel never touches the InitWithdraw row
UserGraphWithdrawEvent::CancelWithdraw(cancel_event) => {
...
if !storage_processor
.update_graph_runtime(
&GraphRuntimeUpdate::new(instance_id, graph_id)
.with_bridge_out_start_at(0)
.with_init_withdraw_tx_hash("".to_string()),
)
.await?
// graph_maintenance_tasks.rs:230-239, the Pending-only consumer
let goat_tx_records = storage_processor
.get_goat_tx_record_by_processing_status(
&GoatTxType::InitWithdraw.to_string(),
&GoatTxProcessingStatus::Pending.to_string(),
)
.await?;
// localdb.rs:3675-3686, unconditional status write, no CAS guard
sqlx::query!(
"UPDATE goat_tx_record
SET processing_status = ?
where instance_id = ?
AND graph_id = ?
AND tx_type = ?",
Provenance and dupe check
This is one of the two adjacent defects explicitly left open in the repository's own TLA+ audit (audit/TLAPlus-20260710.md, Finding 8 followups). It is distinct from public issue #462: #462 covers the KickoffReady message being dropped in handle.rs after the message was already queued; here the message is never queued at all. No existing issue or PR covers the sticky merge or the cancel/reinit chain (checked all 41 issues and 420 PRs as of 2026-09-14).
Security impact
The re-initiated withdrawal never re-enters the operator kickoff pipeline; user withdrawal funds stay locked in local operator bookkeeping until manual DB repair. Freeze / availability, not theft.
Source only review; no PoC transactions were performed; no live system was contacted.
Summary
goat_tx_recordrows of typeInitWithdrawlatch permanently onProcessed.upsert_goat_tx_recordrefuses to overwrite an existingProcessedstatus and pins a non-empty storedtx_hash,CancelWithdrawnever resets theInitWithdrawrow, and the only consumer scansPendingrows only. Result: after a user cancels a withdrawal and re-initiates it on the same graph, the operator node never queues a secondKickoffReady, and the re-initiated withdrawal is stuck in local bookkeeping until manual DB repair.Security impact: loss or lock-up of user withdrawal funds at the operator bookkeeping layer (freeze), plus incorrect state transition. No fund theft path identified.
Affected component
1ff9a4aeced45309a097eeba5955e26164302ab3(current HEAD at time of review)crates/store/src/localdb.rs,node/src/scheduled_tasks/event_watch_task.rs,node/src/scheduled_tasks/graph_maintenance_tasks.rsDescription
Three pieces combine:
upsert_goat_tx_record(crates/store/src/localdb.rs:3569-3583) keeps the storedprocessing_statuswhen the existing rowis_processed()(exactlyProcessed,schema.rs:843-844) and keeps the stored non-emptytx_hash. A newPendingInitWithdrawfor the same(instance_id, graph_id, tx_type)key therefore staysProcessedwith the old hash.CancelWithdrawhandler (node/src/scheduled_tasks/event_watch_task.rs:361-391) clears graph runtime fields and records a separateCancelWithdrawrow with statusSkippedunder a different tx_type. It never resets theInitWithdrawrow.get_user_init_withdraw_graphs(node/src/scheduled_tasks/graph_maintenance_tasks.rs:230-239) selectsInitWithdrawrows with statusPendingonly, anddetect_init_withdraw_callmarks rowsProcessedvia an unconditional UPDATE with no guard (graph_maintenance_tasks.rs:277-284,localdb.rs:3675-3686).How it can be exploited (normal user flow, no privileges)
initWithdraw.detect_init_withdraw_callenqueuesKickoffReadyand flips the row toProcessed.CancelWithdrawleaves theInitWithdrawrowProcessed.Pendingrecord with the new tx hash, but the sticky merge keepsProcessedand the old hash.Pending-only scanner never returns that graph again, so no secondKickoffReadyis ever queued.Recovery requires manual DB surgery; no self-healing path exists.
Steps to reproduce
InitWithdrawfor a graph; confirmdetect_init_withdraw_callmarks the goat_tx_recordProcessedand enqueuesKickoffReady.CancelWithdrawfor the same graph; confirm theInitWithdrawrow staysProcessed(event_watch_task.rs:361-391never touches it).InitWithdrawfor the same(instance_id, graph_id); confirmupsert_goat_tx_recordkeeps theProcessedstatus and the oldtx_hash(localdb.rs:3569-3583).detect_init_withdraw_callagain;get_user_init_withdraw_graphsreturns nothing for that graph, soKickoffReadyis never re-enqueued.Proof of concept (source level)
No PoC transaction was performed and no live system was contacted; source-only review at
1ff9a4a:Provenance and dupe check
This is one of the two adjacent defects explicitly left open in the repository's own TLA+ audit (
audit/TLAPlus-20260710.md, Finding 8 followups). It is distinct from public issue #462: #462 covers theKickoffReadymessage being dropped inhandle.rsafter the message was already queued; here the message is never queued at all. No existing issue or PR covers the sticky merge or the cancel/reinit chain (checked all 41 issues and 420 PRs as of 2026-09-14).Security impact
The re-initiated withdrawal never re-enters the operator kickoff pipeline; user withdrawal funds stay locked in local operator bookkeeping until manual DB repair. Freeze / availability, not theft.
Source only review; no PoC transactions were performed; no live system was contacted.