Repository navigation
[F97] Speculative Execution Marking Causes Permanent Operation Pool Eviction After Reorg #5028
Description
⚠️ Warning — This finding was routed to an issue rather than a PR because it touches consensus / block-graph processing or block/operation validity rules. Changing what a node accepts, rejects, or how it advances state can make patched and unpatched nodes diverge (a network split), so it requires careful design and a coordinated, version-gated (MIP / hard-fork) rollout rather than a self-contained PR.
Source
AI security-scan Finding 97 (severity: medium). Tracked in SUBMISSIONS.md as F97.
Affected file: massa-pool-worker/src/operation_pool.rs
Summary
OperationPool::get_execution_statuses computes whether operations look executed, and OperationPool::prefilter_ops removes operations from the pool when they have any execution marker. The problem is that an operation marked as executed only in candidate history is treated like a positively executed operation and is dropped during pool refresh. If that candidate history is later removed during a rollback, the execution mark disappears, but the operation is not put back into the pool. This can keep a victim transaction out of the pool until manual resubmission and can affect ordering, censorship resistance, and MEV.
Root cause
The main root cause is in OperationPool::get_execution_statuses. It collapses the state (Some(speculative_status), None) into a positive execution marker. This treats an operation that was executed only in candidate history as executed, even though it is not final.
A second root cause is in OperationPool::prefilter_ops. This function drops every operation that has any execution marker at all. Because of that behavior, operations marked only by speculative candidate execution are removed from the pool during refresh.
A third root cause is the missing recovery path after rollback. When candidate history is later truncated on a rollback, the speculative execution mark disappears, but in the scoped code there is no reinsertion path after OperationPool::prefilter_ops has already dropped the operation references.
Affected code
fn get_execution_statuses(&self) -> PreHashMap<OperationId, bool> {
let op_ids: Vec<OperationId> = self.sorted_ops.iter().map(|op_info| op_info.id).collect();
self.channels
.execution_controller
.get_ops_exec_status(&op_ids)
.into_iter()
.zip(op_ids)
.filter_map(
|((spec_status, final_status), op_id)| match (spec_status, final_status) {
(Some(_), Some(_)) => Some((op_id, true)),
(Some(_), None) => Some((op_id, false)),
_ => None,
},
)
.collect()
}
...
// filter out ops that have been executed in final or candidate slots
// TODO: in the re-execution followup, we should only filter out final-executed ops here (exec_status == Some(true))
if retain {
retain = !exec_statuses.contains_key(&op_info.id);
}Recommendation
Distinguish speculative candidate execution from finalized or active blockclique execution when computing operation execution status. OperationPool::get_execution_statuses should not treat (Some(speculative_status), None) as a definitive execution result, and OperationPool::prefilter_ops should avoid evicting operations based solely on candidate-history markers.
Only remove operations from the pool when execution is confirmed by a durable source, such as finalized history or another state that is guaranteed to survive rollback. If speculative execution information must be tracked, keep it separate from pool-eviction logic and use it only for temporary scheduling or deduplication decisions.
Add a rollback-safe recovery path so that operations removed due to non-final execution state are reinserted or revalidated when candidate history is truncated. Ensure pool refresh logic is reorg-aware and can reconstruct eligibility from canonical state after rollback, preventing operations from remaining permanently excluded unless they were actually finalized or otherwise invalid.
Why P0 / breaking
This change affects consensus, wire/serialization format, signatures, economics, block/tx validity, or otherwise requires a coordinated (versioned / hard-fork) rollout. It is therefore filed as an issue for design discussion rather than a direct PR. See SUBMISSIONS.md Part D.
Activity
damip commentedon Sep 10, 2026
Checked this one in depth.
WHAT IS TRUE
active_history.executed_ops_changes(blockclique blocks that are not final yet).prefilter_opsevicts on any mark. A blockclique change truncates the active history (execute_candidate_slot), the mark vanishes, and the op is already gone from every pool that refreshed in between.checked_operations, LRU of 1M entries, so days on mainnet). Only the API node's own pool gets it. The user needs a new op (new fee or expiry). So the pool keeping the op is the only automatic recovery path.WHAT IS NOT TRUE
IncludeOperationError("operation was executed previously"), errors do not interrupt the slot), block validity is unchanged. Wrong routing template.operation_pool.rs, and see point 2.WHAT TO DO