Skip to content

Expect revert#1061

Merged
d-xo merged 11 commits intomainfrom
expectRevert
May 8, 2026
Merged

Expect revert#1061
d-xo merged 11 commits intomainfrom
expectRevert

Conversation

@d-xo
Copy link
Copy Markdown
Collaborator

@d-xo d-xo commented May 6, 2026

Description

Implements the following subset of the forge expectRevert family of cheatcodes:

  • expectRevert()
  • expectRevert(bytes)
  • expectRevert(bytes4)
  • expectRevert(address)
  • expectRevert(bytes4,address)
  • expectRevert(bytes,address)
  • expectPartialRevert(bytes4)
  • expectPartialRevert(bytes4,address)

I did my best to stay forge compatible here, but I made an exception regarding the handling of the variants that expect an address in relation to reverts within calls to create, where I instead implemented a consistent semantics and filed a (partially fixed) bug against forge.

Checklist

  • tested locally
  • added automated tests
  • updated the docs
  • updated the changelog

d-xo and others added 10 commits May 5, 2026 18:30
ports the positive test cases from foundry's
testdata/default/cheats/ExpectRevert.t.sol into
test/contracts/pass/expectRevert.sol and adds symbolic negative tests
in test/contracts/fail/expectRevert.sol.

count overloads, _expectCheatcodeRevert, the precompile call test, and
the inline-assembly test_f case are excluded.

negative tests use a symbolic argument so the failing branch produces a
counterexample rather than terminating with all-branches-reverted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cheat captures length vm.frames at call time. finishFrame consumes
the expectation when the frame at that depth pops and rewrites the
frame result.

CALL-frame matches turn into a return of 8192 zero bytes
(dummySuccessReturn) so solidity's return-data decoders see a normal
return.

CREATE-frame matches keep the FrameReverted (so the parent CREATE
machinery sees a normal failure) and afterwards drop the pushed 0
from the stack and push 0x...01 (dummyCreateAddress). matches forge's
DUMMY_CREATE_ADDRESS so the LHS of a swallowed `new C()` is the same
value across both tools.

matchExpectedRevert strips the Error(string) abi prefix from the
actual buffer before comparing, so expectRevert("foo") matches an
actual whose payload is the encoded `revert("foo")`.

recordActualReverterIfFirst skips CREATE frames and the cheatcode
return frame; first non-skipped FrameReverted wins. matches forge's
behaviour of recording reverted_by only on CALL boundaries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
finishFrame only fired the cheat-depth check on equality. an
expectation consumed by no sub-call silently passed when the test
function popped above the cheat depth. add a strict-less-than
branch that emits an assertion-failure revert with forge's
"call didn't revert at a lower depth than cheatcode call depth"
message.

extend pass and fail test coverage for double-set, no-subcall,
body-revert, empty-string, CREATE2, intermediate-cheat,
delegatecall, full-match-with-extra-args, and reverter-mismatch
boundaries.
consumeExpectedRevert returned FrameReturned dummySuccessReturn
for matched CALL frames, routing through finishFrameNoExpectation's
FrameReturned branch and skipping revertContracts and revertSubstate.
storage written by the reverted callee persisted past the swallow.

now returns FrameReverted dummySuccessReturn paired with a SwallowCall
flag. the FrameReverted body rolls back contracts and substate, copies
the dummy bytes to caller memory and returndata, reclaims gas, and
pushes 0. the SwallowCall fixup replaces the pushed 0 with 1 so the
surrounding bytecode sees a normal success.

flag type widens from Bool to SwallowKind = NoSwallow | SwallowCreate
| SwallowCall. the previous create-swallow path becomes SwallowCreate.

add prove_expectRevertCallRollsBackState (regression for the bug)
and prove_expectRevertCreateRollsBackState (covers the create branch).
- decodeBuf [AbiStringType] reads the head offset in stripErrorPrefix;
  the previous hand-rolled skip assumed offset = 32
- return Either String Bool. short-circuits on Right False so an
  indeterminate axis does not mask a known no-match
- partial-prefix path reads first 4 bytes via Expr.readByte, so a
  symbolic remainder does not block the comparison
- normalize both sides of the full-match by stripping any
  Error(string) wrapper. covers wrapped expected vs raw actual as
  well as the inverse
- reverter check only decides on LitAddr/LitAddr. SymAddr equality
  is unsound under path constraints binding fresh names to literals
- hoist errorMsg out of cheatActions; expectRevertFailureBuf reuses
  it

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- OpRevert assigns expectedRevert.actualReverter = vm.state.contract
  first-wins. inlines and replaces the previous
  recordActualReverterIfFirst helper which skipped CREATE frames.
- CREATE-frame reverts record the would-be-deployed address. CALL-frame
  reverts record the called contract.
- foundry issue 14613: vm.expectRevert(addr) for a top-level CREATE
  direct revert is silently unchecked. foundry PR 14615 fixes it by
  gating on (curr_depth <= expected_revert.depth) and recording the
  outermost CREATE at boundary depth. we record the innermost reverter
  uniformly instead.
- expectRevertFailureBuf renamed to assertionFailedBuf. used by both
  the mismatch path and the depth-violation path.
- matchExpectedRevert returns RevertMatch (Match | Mismatch |
  Indeterminate String) instead of Either String Bool. combine
  short-circuits Mismatch over Indeterminate on either axis.
- comments by dummySuccessReturn and dummyCreateAddress link to
  foundry's revert_handlers.rs.
- new tests: prove_expectRevert_create_wrong_reverter,
  prove_expectRevert_call_wrapping_create_wrong_reverter (must fail);
  prove_expectRevertsCreate2WithReverter (uses CREATE2 for a
  deterministic expected address).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fold consumeExpectedRevert and currentFrameIsCreate into the matching
branch of finishFrame and drop the SwallowKind ADT that existed only
to glue the two functions. move poppingCheatFrame into a where helper
since finishFrame is its only caller.
distinguish reverter mismatch cases in matchExpectedRevert: SymAddr-vs-SymAddr
now returns Match/Mismatch on text equality, and the residual case becomes
internalError since only GVar remains.

reword the two unsatisfied-expectation messages: "expected call did not revert"
when the matching call returns successfully, and "expected revert never
occurred" when the cheat scope exits without any matching call.
@d-xo d-xo requested a review from msooseth May 6, 2026 17:12
Copy link
Copy Markdown
Collaborator

@msooseth msooseth left a comment

Choose a reason for hiding this comment

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

Actually, really nice!

@d-xo d-xo merged commit 226b897 into main May 8, 2026
10 checks passed
@d-xo d-xo deleted the expectRevert branch May 8, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants