Back to Foundry

Enumerable loop removal

crates/lint/docs/enumerable-loop-removal.md

1.8.15.5 KB
Original Source

Enumerable loop removal

Severity: High ID: enumerable-loop-removal

Flags remove on an EnumerableSet inside a loop that also iterates the same set with at.

What it does

Reports a call to EnumerableSet.remove inside a loop that reads the same set with EnumerableSet.at at the loop's own ascending index. Calls are resolved through the type checker, so both the using for method form (set.remove(...)) and the library-qualified form (EnumerableSet.remove(set, ...)) are recognized, named arguments included. The library is identified only by being a library named exactly EnumerableSet; its origin and implementation are not verified, so a same-named user library can match while a renamed fork does not.

The report requires a shape that can be judged without a flow analysis:

  • an unconditional ascending cadence: a bare loop index stepped upward on the straight line of the body, written i++, i += 1, i = i + 1, or i = 1 + i (a step of more than one and an unchecked step count too). A for's next-step, a while's in-body counter, and a do-while's all qualify. Every write to that index must be one of these ascending forms; a decrement, reset, or unrecognized write leaves the loop unreported;
  • an at read at that cadence: set.at(i) for the cadence variable i;
  • a remove on the same set. Calls inside short-circuit and ternary expressions count when their arm may execute; an arm that a literal boolean condition proves unreachable is skipped;
  • a straight-line statement body: no if, try, break, continue, return, revert, unconditional require(false)/assert(false), inline assembly, or nested loop.

Two set operands name the same set when they name the same storage path: a base variable followed by struct fields and literal mapping keys. A local storage reference is another name for what its last straight-line binding gave it, resolved where the loop runs, so set = holders iterates holders, and a reference rebound only after the loop still names what it did inside it. An operand the analysis cannot read (a varying mapping key, a call result, a reference rebound under a condition) is matched against every set, reporting a possibly-safe removal rather than missing an unsafe one.

These stay clean:

  • remove in a loop without at is the recommended collect-then-remove pattern;
  • at in a loop without remove is a plain read;
  • remove outside a loop is fine;
  • at on a different set instance, including two struct fields or two literal mapping keys;
  • at at an index the loop never advances (remove(at(0)), a constant, a stationary cursor, a no-op step like i += 0): the swap refills the read position;
  • the same function names outside the EnumerableSet library.

Deliberately unreported

The detector omits statement shapes that need control-flow or value reasoning to decide whether removal corrupts iteration. This leaves some genuine corruptions unreported rather than guessing about their safe lookalikes:

  • a removal under statement-level control (if (cond) set.remove(set.at(i)) under an unconditional i++). A call inside a short-circuit or ternary expression is still scanned and can report unless a literal boolean proves that expression arm unreachable;
  • a removal followed by continue, which comes round on the shrunk set;
  • a removal in a nested loop, or in a try clause that falls through;
  • a removal inside a statement-level exit condition whose short-circuit operand can steer past that exit;
  • an index that is a straight-line copy of the cadence (idx = i; set.at(idx)) or composite arithmetic on it;
  • a member or indexed cursor, or a cursor paced only inside a nested loop;
  • any descending traversal. Removing the current tail while walking backward is safe, but removing another value can move an already-read tail into an unvisited slot; the detector proves neither relationship and reports neither case;
  • a helper iterating one storage-reference parameter and removing through another, handed the same set twice.

This intentionally trades recall for a narrow, predictable reporting shape. The canonical ascending remove(set.at(i)) loop is covered, while real corruptions outside that shape are missed. Within the supported shape, unresolved set operands are treated as possible aliases, so the detector can also warn when two operands are distinct at runtime.

The argument passed to remove is not related back to the value returned by at. Consequently, the lint can report a safe same-set removal, such as removing the current tail while separately reading the set at the ascending cadence, because tail removal does not shift another element into the walked prefix.

Why is this bad?

EnumerableSet.remove is swap-and-pop: it moves the last element into the removed slot and shrinks the set. Iterating by an ascending index with at while removing skips the swapped-in elements or reads out-of-bounds indices, so some elements are silently never visited.

Example

Bad

solidity
for (uint256 i = 0; i < set.length(); i++) {
    set.remove(set.at(i));
}

Good

solidity
// removing everything: EnumerableSet has a dedicated function for it,
// much cheaper than removing one element at a time
set.clear();

// removing selectively: collect during the loop, remove after it
address[] memory toRemove = new address[](set.length());
for (uint256 i = 0; i < set.length(); i++) {
    if (shouldRemove(set.at(i))) toRemove[i] = set.at(i);
}
for (uint256 i = 0; i < toRemove.length; i++) {
    set.remove(toRemove[i]);
}