Summary
fetch_history_events tight-loops without sleep or break when from_height exceeds the indexer's finalized height. The branch logs "will finish" then continues an infinite loop, re-calling get_sync_block_height as fast as the RPC client allows. Other empty/error tip paths sleep 500ms; this one spins. The spawned task has no cancellation token, so the spin persists until process restart.
Security impact: local resource exhaustion (CPU spin) on the event-watch task, stalling catchup progress for that contract. No fund theft path.
Affected component
- Repository: GOATNetwork/bitvm-node
- Commit reviewed:
1ff9a4aeced45309a097eeba5955e26164302ab3 (current HEAD at time of review)
- Files:
node/src/scheduled_tasks/event_watch_task.rs
- Component class: Implementation (event watcher, Gateway catchup)
Description
In fetch_history_events (node/src/scheduled_tasks/event_watch_task.rs, event_watch_task.rs:1120-1125 at the pin):
if watch_contract.from_height > current_finalized {
info!(
"Contract {task_name} fetch history events will finish, as current finalize height: {current_finalized} is litter than watch from height: {}",
watch_contract.from_height,
);
continue;
}
The continue re-enters the loop, which immediately re-calls get_sync_block_height. Compare the sibling branches in the same loop: the empty-tip path (Ok(None)) and the error path both sleep(Duration::from_millis(500)).await; before continue. Only this arm spins unbounded.
Reachability: the condition becomes true when the indexer tip regresses below an already-advanced from_height while the loop is running (subgraph reindex/reset, replica lag) — the cursor only advances via to_height + 1, so a tip regression mid-catchup is the trigger. A single fetcher is sufficient; the concurrent-spawner race in #466 can amplify it (a slower fetcher rewinding from_height past the tip) but is not required. The task is spawned via tokio::spawn in monitor_events_item with no cancellation token, so once spinning it does not stop until process restart.
Steps to reproduce
- Start history catchup on a watch contract (node behind the tip).
- While
fetch_history_events is running, make the indexer tip regress below the cursor's from_height (subgraph reindex/reset or replica lag).
- Observe the task: the "will finish" log line repeats with no sleep between iterations; CPU usage of the task pins until process restart.
Proof of concept (source level)
Source-only review; no PoC transaction; no live system contacted. Byte-exact at 1ff9a4a:
// event_watch_task.rs:1120-1125
if watch_contract.from_height > current_finalized {
info!(
"Contract {task_name} fetch history events will finish, as current finalize height: {current_finalized} is litter than watch from height: {}",
watch_contract.from_height,
);
continue;
}
Sibling paths that DO sleep (same loop, for contrast):
Ok(None) => {
warn!("fetch_history_events:fail to get graph sync block height, will try later, empty value returned");
sleep(Duration::from_millis(500)).await;
continue;
}
Dupe check
No existing issue or PR covers the busy-loop (checked all 41 issues and 420 PRs as of 2026-09-14). Related but distinct: #466 covers the concurrent-spawner race that can rewind from_height; this covers the unbounded spin after a rewind or tip regression.
Security impact
Local DoS / resource griefing of the event-watch history task: CPU spin until process restart, stalling catchup progress for the affected contract. Low severity. Source only review; no PoC transactions were performed; no live system was contacted.
Summary
fetch_history_eventstight-loops without sleep or break whenfrom_heightexceeds the indexer's finalized height. The branch logs "will finish" thencontinues an infiniteloop, re-callingget_sync_block_heightas fast as the RPC client allows. Other empty/error tip paths sleep 500ms; this one spins. The spawned task has no cancellation token, so the spin persists until process restart.Security impact: local resource exhaustion (CPU spin) on the event-watch task, stalling catchup progress for that contract. No fund theft path.
Affected component
1ff9a4aeced45309a097eeba5955e26164302ab3(current HEAD at time of review)node/src/scheduled_tasks/event_watch_task.rsDescription
In
fetch_history_events(node/src/scheduled_tasks/event_watch_task.rs,event_watch_task.rs:1120-1125at the pin):The
continuere-enters the loop, which immediately re-callsget_sync_block_height. Compare the sibling branches in the same loop: the empty-tip path (Ok(None)) and the error path bothsleep(Duration::from_millis(500)).await;beforecontinue. Only this arm spins unbounded.Reachability: the condition becomes true when the indexer tip regresses below an already-advanced
from_heightwhile the loop is running (subgraph reindex/reset, replica lag) — the cursor only advances viato_height + 1, so a tip regression mid-catchup is the trigger. A single fetcher is sufficient; the concurrent-spawner race in #466 can amplify it (a slower fetcher rewindingfrom_heightpast the tip) but is not required. The task is spawned viatokio::spawninmonitor_events_itemwith no cancellation token, so once spinning it does not stop until process restart.Steps to reproduce
fetch_history_eventsis running, make the indexer tip regress below the cursor'sfrom_height(subgraph reindex/reset or replica lag).Proof of concept (source level)
Source-only review; no PoC transaction; no live system contacted. Byte-exact at
1ff9a4a:Sibling paths that DO sleep (same loop, for contrast):
Dupe check
No existing issue or PR covers the busy-loop (checked all 41 issues and 420 PRs as of 2026-09-14). Related but distinct: #466 covers the concurrent-spawner race that can rewind
from_height; this covers the unbounded spin after a rewind or tip regression.Security impact
Local DoS / resource griefing of the event-watch history task: CPU spin until process restart, stalling catchup progress for the affected contract. Low severity. Source only review; no PoC transactions were performed; no live system was contacted.