Skip to content

[Security] detect_init_withdraw_call latches InitWithdraw to Processed with no KickoffReady when find_graph fails (withdrawal stuck, no retry) #467

Description

@byfor8

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

  1. 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.
  2. Run detect_init_withdraw_call: the else branch warns but does not return or continue; the row is marked Processed in the same transaction.
  3. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions