Skip to content

fix(lint): correlate emits with writes order-independently in missing-events-access-control - #16651

Open
gomesalexandre wants to merge 1 commit into
foundry-rs:masterfrom
gomesalexandre:fix_missing_events_emit_before_write
Open

fix(lint): correlate emits with writes order-independently in missing-events-access-control#16651
gomesalexandre wants to merge 1 commit into
foundry-rs:masterfrom
gomesalexandre:fix_missing_events_emit_before_write

Conversation

@gomesalexandre

Copy link
Copy Markdown
Contributor

mark_event's correlation only matched writes already recorded when an emit statement was visited - it walked self.state.writes forward-only, marking matches as evented. So an emit written BEFORE the state change it documents could never mark that later write as evented, producing a false "missing event" warning on an equally valid, common ordering:

function setOwner(address newOwner) public {
    emit OwnershipTransferred(owner, newOwner);  // emit BEFORE the write
    owner = newOwner;                             // flagged, even though it's covered
}

vs. the currently-passing (because it's after the write):

function setOwner(address newOwner) public {
    owner = newOwner;
    emit OwnershipTransferred(owner, newOwner);   // this ordering works fine
}

Fix

Defer correlation instead of doing it immediately. record_emit just appends (EventId, Sources) facts to a new state.emits. A new correlate_pending() sweeps all pending emits against all not-yet-evented writes, order-independently, called at scope boundaries:

  • the end of a function body,
  • after each if-branch (before the branch state is discarded/merged),
  • after a loop body (any LoopSource, including do-while, since even a guaranteed-first-iteration can break/continue/revert past the emit on that pass),
  • after each try/catch clause.

Each of these boundaries discards the emits recorded inside that scope before continuing past it - a conditionally-reached emit (inside one if branch, one loop iteration, or one catch clause) must never satisfy a write reachable via a DIFFERENT path that might not have run it. Loop and Try additionally restore each pre-existing write's evented flag after their own correlate_pending() call, since - unlike the if-branch case, which is already isolated by a full state clone-and-merge - that flag isn't otherwise protected from being mutated by a conditionally-reached emit.

Try treats clauses the same way merge_branches treats an if's two arms (each analyzed from its own isolated clone of the pre-try state, then recombined), generalized to N mutually-exclusive clauses via a new merge_try_clauses, and is exit-aware: a clause whose body always exits (return/revert) is excluded from the taint/alias merge (mirroring merge_branches's existing handling of an exiting if/else arm), since code after the try never observes what an always-exiting clause did.

Along the way this also closes a related, pre-existing defect that predates this diff: the old immediate, scope-unaware correlation let an emit inside one mutually-exclusive branch/loop/try-clause retroactively satisfy a write reachable only outside it. Regression fixtures for this are included and verified to already fail against the fully original, unpatched code.

Known, deliberately out-of-scope limitations (disclosed, not fixed)

  • Two branches/clauses that both emit the same matching event can still produce a false positive for a write after them - this file has no event-fact intersection across branches/clauses, mirroring how merge_branches already handles writes.evented the same way.
  • A conditionally-evaluated call (inside &&/||/a ternary) is analyzed as if it were always called - this matches the file's pre-existing, unconditional call-inlining (analyze_call), not something this diff changes.
  • Within a single loop body, a write that always executes followed by a conditionally-skippable emit (via break/continue/revert) can still miss a warning - the analyzer has no statement-reachability model at all, for loops or otherwise. Confirmed to exist identically in the original, unpatched code.
  • A try/catch clause that always reverts is treated the same as one that returns for the writes-evented AND-rule (a conservative false positive - an extra, avoidable warning - never a false negative), since a reverted write never actually persists but the two aren't distinguished here.

All four are the FALSE-POSITIVE direction (or a pre-existing gap unaffected by this diff), never a new false negative introduced by this change - happy to split any into follow-up issues if a maintainer wants them tracked.

Testing

  • Real red-before-green: reverted just the source fix (multiple times across iterations) and confirmed every new fixture case genuinely fails on unfixed code with the exact described behavior, then confirmed green again after restoring.
  • Full lint UI fixture suite: 97/97 passing (all existing cases unaffected).
  • 9 new fixture cases covering: emit-before-write at the top level and nested in an if, a matching event confined to one if-branch/loop/try-clause not satisfying a write outside it, do-while+break skipping an emit, a write before a loop/try with the matching emit inside it, cross-clause (success-emits/catch-writes) isolation, and a reverting catch not blocking credit for taint/aliases while still requiring its own event coverage for writes.
  • forge-lint unit tests pass. Clippy clean (-D warnings). cargo fmt --check clean on stable - nightly (used by this repo's CI) wasn't available in this environment to verify against.

receipts

no runtime UI change - forge-lint's own test suite is the receipt for this PR (static-analysis fixture corpus, not a runnable app).

…-events-access-control

The mark_event correlation only matched writes already recorded when an emit
statement was visited, so an emit written BEFORE the state change it documents
could never mark that later write as evented - producing a false 'missing
event' warning on an equally valid, common ordering:

    emit OwnershipTransferred(owner, newOwner);
    owner = newOwner;

Fix: defer correlation. record_emit just appends (EventId, Sources) facts;
correlate_pending() sweeps all pending emits against all not-yet-evented
writes, called at scope boundaries (function end, each if-branch, each loop,
each try/catch clause) so a conditionally-reached emit can never satisfy a
write outside the scope that might not have run it. Loop and Try additionally
restore each pre-existing write's evented flag after their own
correlate_pending() call, since that mutation isn't otherwise isolated the way
the if-branch clone-and-merge already isolates it.

Along the way this also closes a related, pre-existing defect (present before
this diff too): a scope-unaware immediate correlation let an emit inside one
mutually-exclusive branch/loop/try-clause retroactively satisfy a write
reachable outside it.

Known, deliberately out-of-scope limitations (disclosed, not fixed):
- Two branches/clauses that both emit the same matching event can still be
  treated as a false positive for a write after them (this file has no
  event-fact intersection across branches, mirroring how merge_branches
  already treats writes).
- A conditionally-evaluated call (inside &&/||/?:) is analyzed as if always
  called, matching this file's pre-existing, unconditional call-inlining.
- Within a single loop body, a write that always executes followed by a
  conditionally-skippable emit (via break/continue/revert) can still miss a
  warning - the analyzer has no statement-reachability model at all.
- A try/catch clause that always reverts is treated the same as one that
  returns for the writes-evented AND-rule (conservative false positive, not a
  false negative) since the two aren't distinguished.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh6V2uPTUqauqq45BM7m5k
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

✅ Changelog found

The deterministic check will validate the changed entry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant