Skip to content

[Security] Sticky Processed on InitWithdraw goat_tx_record permanently blocks re-initiated withdrawals after user cancel (fund lock-up) #465

Description

@byfor8

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:

  1. 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.
  2. 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.
  3. 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)

  1. User calls initWithdraw. detect_init_withdraw_call enqueues KickoffReady and flips the row to Processed.
  2. User cancels. CancelWithdraw leaves the InitWithdraw row Processed.
  3. 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.
  4. 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

  1. Watch InitWithdraw for a graph; confirm detect_init_withdraw_call marks the goat_tx_record Processed and enqueues KickoffReady.
  2. Observe CancelWithdraw for the same graph; confirm the InitWithdraw row stays Processed (event_watch_task.rs:361-391 never touches it).
  3. 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).
  4. 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.

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