Skip to content

feat(lint): add write-after-write#14915

Merged
grandizzy merged 9 commits into
masterfrom
mablr/write-after-write_lint
May 29, 2026
Merged

feat(lint): add write-after-write#14915
grandizzy merged 9 commits into
masterfrom
mablr/write-after-write_lint

Conversation

@mablr
Copy link
Copy Markdown
Collaborator

@mablr mablr commented May 26, 2026

Description

Closes OSS-84

Add write-after-write lint.

Detects redundant consecutive storage writes to the same variable where the first value is overwritten before being read, wasting an SSTORE.

PR Checklist

  • Added Tests
  • Added Documentation
  • Breaking changes

Detects redundant consecutive storage writes to the same variable where
the first value is overwritten before being read, wasting an SSTORE.
@mablr mablr changed the title feat(lint): feat(lint): add write-after-write May 26, 2026
@mablr mablr changed the title feat(lint): add write-after-write feat(lint): add write-after-write May 26, 2026
Copy link
Copy Markdown
Collaborator

@figtracer figtracer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctness

Comment thread crates/lint/src/sol/gas/write_after_write.rs Outdated
@mablr mablr requested a review from figtracer May 26, 2026 12:26
figtracer
figtracer previously approved these changes May 26, 2026
Copy link
Copy Markdown
Member

@mattsse mattsse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for a few correctness gaps in write-after-write.

P2: Binary and Ternary are walked linearly here: write_after_write.rs#L204-L215. This can false-positive across &&/|| short-circuiting and ternary arms, and conflicts with the docs' “flat sequence” scope: book#L16-L20. Suggest treating conditional/short-circuit expression boundaries like branches: process condition reads, then clear or analyze each reachable arm with separate pending state.

P2: The lint only implements check_function: write_after_write.rs#L22-L33, so modifier bodies are not analyzed even though placeholder handling exists: write_after_write.rs#L114-L117. Suggest adding modifier-body traversal or removing/de-scoping placeholder logic and tests until modifiers are actually checked.

P3: Calls clear pending without first walking the callee/args: write_after_write.rs#L171-L174, write_after_write.rs#L216-L218. Solidity evaluates call arguments before the call, so nested overwrites in args are missed. Suggest walking callee/arguments first, then clearing pending before modeling the call itself.

Comment thread crates/lint/src/sol/gas/write_after_write.rs
Comment thread crates/lint/src/sol/gas/write_after_write.rs
mablr added 2 commits May 28, 2026 12:12
- Fix tuple/destructuring LHS: add `process_assignment_lhs` helper that
  recurses through tuple components, tracking each as a write rather
  than falling through to `collect_reads` which treated them as reads
- Fix pre/post inc/dec: record the operator's write in pending after the
  read so `++x; x = v` correctly flags the dead `++x` write
- Fix named call args: walk `{value:, gas:, salt:}` options via a shared
  `walk_named_args` helper before clearing pending in both
  `process_expr` and `collect_reads`
- Fix emit over-clearing: peel the call in `StmtKind::Emit` and walk
  args directly without hitting the `ExprKind::Call` clear path
- Fix nested `delete` in `collect_reads`: delegate to `process_expr` so
  the write is tracked correctly, matching how nested `Assign` is
  handled
- Fix `ExprKind::Err` in `collect_reads`: clear pending for consistency
  with `StmtKind::Err`
- Fix unreachable code false positives: break from `check_block` after
  any terminal statement so writes in unreachable code are not analysed
- Update docs to reflect actual analysis scope (drops "flat sequence"
  claim)
figtracer
figtracer previously approved these changes May 28, 2026
@grandizzy
Copy link
Copy Markdown
Collaborator

A few things worth addressing before merge, mostly around the data-flow precision in check_stmt / collect_reads:

Real false positives from nested terminators

check_block only stops when the immediate statement is Return | Revert | Break | Continue. Terminators inside nested blocks/branches don't propagate, so unreachable code is still treated as live and the first write gets flagged:

{ return; }
x = 1;
x = 2;                       // unreachable, but x = 1 will be flagged

if (c) return; else revert E();
x = 1;
x = 2;                       // unreachable on both paths, but x = 1 will be flagged

while (c) {
    { break; }
    x = 1;
    x = 2;                   // unreachable after break in this iteration
}

Fix: thread a small Flow { Continue, StopCurrentBlock } through check_stmt / check_block. Block / UncheckedBlock propagate from children; If returns StopCurrentBlock only if both branches stop; Loop always returns Continue (zero-iteration possible).

Inline assembly handling

The match on StmtKind isn't exhaustive without an Assembly / Yul arm. Either solar's pinned HIR has no such variant (in which case fine), or it does and it's silently ignored — which would be unsound, since inline assembly can sload / sstore / call / revert any storage:

x = 1;
assembly { /* may read or overwrite x.slot */ }
x = 2;                       // currently x = 1 likely flagged — unsound

Safe behavior is pending.clear() for the assembly statement. Worth confirming against solar's actual StmtKind enum and adding a fixture.

Tuple diagnostic span is misleading

(x, y) = (1, 2);
x = v;

emits one warning pointing at the whole tuple statement, but only the x slot is dead — y may be live. process_assignment_lhs should pass the component span (e.span) instead of the outer assign_span so the warning points at x specifically.

Likely false negatives worth fixtures (or documenting)

These are the conservative-clear trade-offs; mostly defensible, but should at least be visible as tests:

  • Branch-local dead writes: x = 1; if (c) { y = 2; } x = v;x = 1 dead on both paths, missed (outer pending cleared on If).
  • Both branches assign: if (c) x = 1; else x = 2; x = 3; — both branch writes dead, missed.
  • Short-circuit / ternary in RHS: x = 1; bool b = a && b2; x = 2; and x = 1; uint256 z = c ? 7 : 8; x = 2;x = 1 lost because collect_reads's && / || / Ternary arms unconditionally pending.clear() even when the surrounding context doesn't read x.
  • Pure / view / builtin calls: x = 1; pureFn(); x = 2; or any abi.encode(...) — all calls clear pending. Defensible for reentrancy, but loses many obviously-safe cases.
  • do { x = 2; } while (c); after x = 1 — body executes ≥ 1, but loops always wipe pending.
  • Modifier arguments: writes in f() m(x = 1) live on func.modifiers, not func.body, and aren't analyzed.

Minor soundness / style notes

  • process_assignment_lhs for tuple LHS uses the same assign_span for every component, so two same-statement WAW reports point to identical spans and are hard to distinguish.
  • The Loop(block, _) second field is LoopSource (init/cond/update are desugared into the loop block in HIR) — so that's fine, not a bug.
  • DeclMulti with state-var LHS isn't a real concern: (uint a, x) = … shows up as a tuple Assign, which is handled.
  • x = f(); x = v; where f() may revert is sound — on success the first is dead, on revert the second never runs.
  • emit E(x) correctly doesn't clear pending; reads in args are removed by collect_reads.

Suggested additional fixtures

// Currently FP (or will be after fix) — unreachable code should not flag
function nestedReturn() public {
    { return; }
    x = 1;
    x = 2;
}

function bothBranchesExit(bool c) public {
    if (c) return; else revert();
    x = 1;
    x = 2;
}

function nestedBreak(bool c) public {
    while (c) {
        { break; }
        x = 1;
        x = 2;
    }
}

// Inline assembly — verify soundness
function asmReadsX() public {
    x = 1;
    assembly { let v := sload(0) }
    x = 2;
}

// UX: span should point at the `x` component, not the whole tuple
function tuplePartial(uint256 v) public {
    (x, y) = (1, 2);
    x = v;
}

// Known FNs — document as limitations
function branchMiss(bool c, uint256 v) public {
    x = 1;
    if (c) { y = 2; }
    x = v;
}
function shortCircuitMiss(bool a, bool b) public {
    x = 1;
    bool z = a && b;
    x = 2;
}
function pureCallMiss() public {
    x = 1;
    abi.encode(uint256(0));
    x = 2;
}

Minimal fix suggestion

The three items above (Flow propagation through nested terminators, explicit assembly handling, component-level tuple spans) close the real soundness / UX issues with little added complexity. The conservative-clear false negatives can be left as-is — but adding fixtures for them makes the design intent visible to future maintainers.

write-after-write

Thread a `Flow` enum through `check_stmt`/`check_block` so nested
terminators
(`{ return; }`, both-branch exits, nested `break`) propagate correctly
and
unreachable code is no longer flagged as a false positive.

Fix tuple LHS diagnostic spans to point at the individual component
(`x`) rather
than the whole tuple expression.

Document the inline-assembly false positive (assembly is not yet lowered
to solar's
HIR) and add known-false-negative fixtures for branch-miss,
short-circuit, and
pure-call cases so design intent is visible to future maintainers.
Copy link
Copy Markdown
Collaborator Author

@mablr mablr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grandizzy I fixed your findings in 06e3f72.

@grandizzy grandizzy requested a review from figtracer May 29, 2026 05:13
@grandizzy grandizzy enabled auto-merge (squash) May 29, 2026 07:04
@grandizzy grandizzy merged commit 6d04464 into master May 29, 2026
32 of 34 checks passed
@grandizzy grandizzy deleted the mablr/write-after-write_lint branch May 29, 2026 10:03
@github-project-automation github-project-automation Bot moved this to Done in Foundry May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants