Skip to content

[F97] Speculative Execution Marking Causes Permanent Operation Pool Eviction After Reorg #5028

Closed

Description

@damip

⚠️ 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

  1. added
    bugSomething isn't working
    P0Highest priority
    on Jul 21, 2026
  2. self-assigned this
    on Aug 27, 2026
  3. damip commented on Sep 10, 2026

    @damip
    MemberAuthor

    Checked this one in depth.

    WHAT IS TRUE

    1. The mechanism is real. Speculative marks come from active_history.executed_ops_changes (blockclique blocks that are not final yet). prefilter_ops evicts 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.
    2. Resubmitting the same op does not bring it back: peers drop known op ids (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

    1. Not consensus, not P0, no MIP. The pool is node-local policy. Execution ignores an op that is included twice (IncludeOperationError("operation was executed previously"), errors do not interrupt the slot), block validity is unchanged. Wrong routing template.
    2. Severity: liveness only. Needs a reorg that kicks out a block most nodes had in their blockclique for a few seconds. Rare, not cheap to trigger on demand. Low, not medium. Still worth fixing: it is the re-execution followup already announced by the TODOs in operation_pool.rs, and see point 2.

    WHAT TO DO

    1. [F97] feat: keep speculative ops in pool, but hard skip them #5233 has the right eviction and scoring logic but needs two changes, see the review there. The main one: nothing on the block production path may call execution. Block production must be fast and must never block. Dropping or duplicating a few tx is always preferable to a late block.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P0Highest prioritybugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions