Summary
detect_init_withdraw_call permanently latches the InitWithdraw goat_tx_record to Processed even when the graph lookup fails, without queueing KickoffReady. The Pending-only scanner plus the sticky Processed merge in upsert_goat_tx_record mean the withdrawal is never retried until manual DB repair.
Security impact: operator bookkeeping freeze on the affected withdrawal (availability), no fund theft path.
Affected component
- Repository: GOATNetwork/bitvm-node
- Commit reviewed:
1ff9a4aeced45309a097eeba5955e26164302ab3 (current HEAD at time of review)
- Files:
node/src/scheduled_tasks/graph_maintenance_tasks.rs, node/src/scheduled_tasks/event_watch_task.rs
- Component class: Implementation (operator node withdraw bookkeeping)
Description
detect_init_withdraw_call (node/src/scheduled_tasks/graph_maintenance_tasks.rs:243-287) scans Pending InitWithdraw rows and, per row:
if let Ok(Some(graph)) = tx.find_graph(&graph_id).await {
...
upsert_message(&mut tx, false, graph_id, None, SELF_SENDER.to_string(), Actor::Operator,
GOATMessageContent::KickoffReady(KickoffReady { instance_id, graph_id }), 0, 0).await?;
} else {
warn!("instance_id: {instance_id} graph_id: {graph_id} fail to get graph from db or kickoff txid is none");
}
tx.update_goat_tx_record_processing_status(
&graph_id,
&instance_id,
&GoatTxType::InitWithdraw.to_string(),
&GoatTxProcessingStatus::Processed.to_string(),
).await?;
tx.commit().await?;
The if let Ok(Some(graph)) pattern means the else branch fires on both find_graph Err and Ok(None) (graph row missing, e.g. after a partial DB restore). In both cases the code falls through to the unconditional Processed write. Note the asymmetry: an instance-id mismatch continues before the status write, so the latch only fires on the missing-graph/error path. Once Processed, the sticky merge in upsert_goat_tx_record (crates/store/src/localdb.rs:3569-3583) plus the Pending-only scanner (get_user_init_withdraw_graphs, graph_maintenance_tasks.rs:230-239) mean the row is never seen again: no KickoffReady now, no retry ever.
Ingest narrows the trigger: graph_belongs_to_instance (node/src/scheduled_tasks/event_watch_task.rs:491-494 area) refuses to create the goat_tx_record when the graph row is missing, so the happy path always has a graph. The realistic trigger is find_graph returning Err (transient DB error is treated as a miss by if let Ok(Some(_))) or a restored/replicated DB missing the graph row. No dependency on any other finding.
Steps to reproduce
- Have a
Pending InitWithdraw goat_tx_record for a graph whose row is missing from the graph table (partial restore) or induce a find_graph DB error.
- Run
detect_init_withdraw_call: the else branch warns but does not return or continue; the row is marked Processed in the same transaction.
- Run
detect_init_withdraw_call again: get_user_init_withdraw_graphs returns nothing (status is Processed), so KickoffReady is never queued.
Proof of concept (source level)
Source-only review; no PoC transaction; no live system contacted. Byte-exact at 1ff9a4a:
// graph_maintenance_tasks.rs:252-284
if let Ok(Some(graph)) = tx.find_graph(&graph_id).await {
if graph.instance_id.ne(&instance_id) {
warn!("Graph:{graph_id} recorded instance_id:{} not equal expected instance_id:{instance_id}", graph.instance_id);
continue;
}
upsert_message(
&mut tx, false, graph_id, None, SELF_SENDER.to_string(), Actor::Operator,
GOATMessageContent::KickoffReady(KickoffReady { instance_id, graph_id }), 0, 0,
).await?;
} else {
warn!("instance_id: {instance_id} graph_id: {graph_id} fail to get graph from db or kickoff txid is none");
}
tx.update_goat_tx_record_processing_status(
&graph_id, &instance_id, &GoatTxType::InitWithdraw.to_string(),
&GoatTxProcessingStatus::Processed.to_string(),
).await?;
tx.commit().await?;
Provenance and dupe check
Distinct from public #462 (KickoffReady message dropped in handle.rs when a previous-nonce graph row is missing, after the message was already queued): here the message is never queued and the row is latched in the maintenance scan. Distinct from #465 (cancel/reinit sticky merge): that one latches via upsert_goat_tx_record after a successful kickoff; this one latches at the scan site itself. No existing issue or PR covers it (checked all 41 issues and 420 PRs as of 2026-09-14).
Security impact
The withdrawal is stuck in local operator bookkeeping until manual DB repair. Availability/freeze class, Low severity. Source only review; no PoC transactions were performed; no live system was contacted.
Summary
detect_init_withdraw_callpermanently latches theInitWithdrawgoat_tx_recordtoProcessedeven when the graph lookup fails, without queueingKickoffReady. ThePending-only scanner plus the stickyProcessedmerge inupsert_goat_tx_recordmean the withdrawal is never retried until manual DB repair.Security impact: operator bookkeeping freeze on the affected withdrawal (availability), no fund theft path.
Affected component
1ff9a4aeced45309a097eeba5955e26164302ab3(current HEAD at time of review)node/src/scheduled_tasks/graph_maintenance_tasks.rs,node/src/scheduled_tasks/event_watch_task.rsDescription
detect_init_withdraw_call(node/src/scheduled_tasks/graph_maintenance_tasks.rs:243-287) scansPendingInitWithdrawrows and, per row:The
if let Ok(Some(graph))pattern means theelsebranch fires on bothfind_graphErrandOk(None)(graph row missing, e.g. after a partial DB restore). In both cases the code falls through to the unconditionalProcessedwrite. Note the asymmetry: an instance-id mismatchcontinues before the status write, so the latch only fires on the missing-graph/error path. OnceProcessed, the sticky merge inupsert_goat_tx_record(crates/store/src/localdb.rs:3569-3583) plus thePending-only scanner (get_user_init_withdraw_graphs,graph_maintenance_tasks.rs:230-239) mean the row is never seen again: noKickoffReadynow, no retry ever.Ingest narrows the trigger:
graph_belongs_to_instance(node/src/scheduled_tasks/event_watch_task.rs:491-494area) refuses to create the goat_tx_record when the graph row is missing, so the happy path always has a graph. The realistic trigger isfind_graphreturningErr(transient DB error is treated as a miss byif let Ok(Some(_))) or a restored/replicated DB missing the graph row. No dependency on any other finding.Steps to reproduce
PendingInitWithdrawgoat_tx_record for a graph whose row is missing from thegraphtable (partial restore) or induce afind_graphDB error.detect_init_withdraw_call: theelsebranch warns but does not return or continue; the row is markedProcessedin the same transaction.detect_init_withdraw_callagain:get_user_init_withdraw_graphsreturns nothing (status isProcessed), soKickoffReadyis never queued.Proof of concept (source level)
Source-only review; no PoC transaction; no live system contacted. Byte-exact at
1ff9a4a:Provenance and dupe check
Distinct from public #462 (KickoffReady message dropped in
handle.rswhen a previous-nonce graph row is missing, after the message was already queued): here the message is never queued and the row is latched in the maintenance scan. Distinct from #465 (cancel/reinit sticky merge): that one latches viaupsert_goat_tx_recordafter a successful kickoff; this one latches at the scan site itself. No existing issue or PR covers it (checked all 41 issues and 420 PRs as of 2026-09-14).Security impact
The withdrawal is stuck in local operator bookkeeping until manual DB repair. Availability/freeze class, Low severity. Source only review; no PoC transactions were performed; no live system was contacted.