fix(edr_solidity): solx stack-trace attribution for declaration-level and line-0 DWARF - #1577
Conversation
…ation #1139 moved the crate from crates/tools/ to crates/tool/cli/ without updating project_root(), which has resolved to crates/ instead of the repo root since — gen-execution-api joins its paths onto the wrong directory. One ancestor further up fixes it (and the new caller in gen-solx-fixtures).
…xtures `cargo run -p edr_tool_cli -- gen-solx-fixtures <solx-binary>` regenerates the self-contained solx compiler-output fixtures in `crates/edr_solidity/fixtures`: it splices the `fixtures/sources/` contents into the committed input JSON (whose `content` fields are deliberately empty), runs `solx --standard-json`, and writes solx's verbatim output. Lives next to `gen-execution-api` — the repo's existing home for generators of committed artifacts. stdin is fed from a scoped thread so a large input can't deadlock against the child filling its stdout pipe. Standard-json compilers read stdin to EOF before emitting output, so this cannot fire today, but fixtures grow. The `scenarios` fixture is deliberately not covered: its input also depends on forge-std sources whose contents are scrubbed from the committed JSON, so it stays hardhat-generated.
A self-contained solx fixture (plain contracts, no forge-std) for the provider-path stack-trace tests, regenerable in-repo via `gen-solx-fixtures` — unlike the `scenarios` fixture, which needs the sweep project. The input pins `settings.optimizer.mode` to "1": trace shapes differ per optimizer mode, and "1" is hardhat-solx's default, the pipeline these fixtures stand in for (solx's own default is "3"). Output compiled with solx 0.1.6. The scenario set is complete as a corpus — `GuardedBareRevert`, `ValidatedCounter`(`Caller`) and `ExpectsWord` pin the attribution fixes in this PR, the dispatch-level contracts (payability, missing fallback/receive, calldata decoding, external library linking) are consumed by the stacked coverage PR. Keeping it whole means the file's line numbers — which every golden pins — don't move between the two.
…tack traces solx attributes compiler-generated helper code (calldata decoding, shared revert builders) to the *declaration* of the enclosing function or contract, where solc leaves such code unmapped. Two inference paths broke on that difference: 1. fails_right_after_call / is_call_failed_error compared every post-CALL step location to the call site by strict equality, so declaration-level padding (whose range merely contains the statement) made the heuristics bail and reverts degraded to OtherExecutionError at the contract declaration line. New TraceStrategy::locations_equivalent treats a containing (declaration-level) location as "still at the statement" for solx; solc keeps strict equality. 2. A shared revert helper flattened out of a modifier is attributed to the modified function's declaration line. SolxTraceStrategy:: revert_source_reference now walks the executed steps back from a declaration-line revert to the statement that led there (the require's message-building code keeps its own line), matching solc. Provider-path effects, pinned in solx_stack_trace.rs by a later commit in this PR: - ReturndataSizeError now surfaces at the call site for both a returndata-size mismatch and a typed call to a codeless account (parity with solc >= 0.8.10, which emits no EXTCODESIZE probe for returndata-expecting calls), replacing OtherExecutionError at the contract declaration. - A multi-statement modifier revert reports the failing require's line instead of the function declaration line, removing the provider-level twin of the sweep's NestedModifierRevertTest divergence. The solc strategy is behavior-identical.
Review findings on the walk in SolxTraceStrategy::revert_source_reference: 1. The walk was unbounded — the first resolvable location off the declaration line won, even if it belonged to the dispatcher or an internal function executed earlier in the message. It now only accepts statements of the failing function itself or of a modifier (flattened into its frame, possibly from another file — pure function containment would regress the NestedModifierRevertTest golden); a statement of any other function ends the walk and keeps the declaration reference. 2. Both the trigger and the skip compared bare line numbers, which don't identify a location across files. Declaration matching now requires file identity (via SourceLocation::contains, which checks the source file pointer). 3. The trigger only recognized the function declaration line, though solx also attributes helpers to the enclosing contract declaration; both lines now count as declaration-level padding. The locations_equivalent containment proxy reviewed alongside is kept as-is: it can only over-match when every instruction of a later statement is declaration-attributed, and the parity sweep guards that residual. No golden changes. The `GuardedBareRevert` scenario of the fixture — a modifier's bare revert(), with no message-building code to keep its own line — surfaces the adjacent third gap this constraint set doesn't address: the whole helper is attributed to the contract declaration, whose location has no containing function, so inference degrades before revert_source_reference is consulted. Fixed in the next commit.
A modifier's bare revert() compiles to a shared helper that solx leaves entirely unmapped (no DWARF line at all), and its return data is empty, so every revert heuristic missed and inference fell through to OtherExecutionError at the contract start. check_last_instruction's revert branch now falls back to a strategy hook when the reverting instruction has no resolvable containing function: TraceStrategy::declaration_attributed_failing_function resolves the called function from the calldata selector under solx (solc returns None — behavior-identical, its reverts are mapped). The fallback only engages for REVERT opcodes and only when the calldata decodes for the resolved function, so dispatch-level reverts on undecodable calldata still reach the InvalidParamsError classification below. revert_source_reference now takes Option<&SourceLocation>: an unmapped instruction gets the same walk-back recovery as declaration-attributed padding, with the failing function's start as last resort (unmapped and contract-declaration locations can't become source references themselves). Effect on the GuardedBareRevert scenario: RevertError at the revert() statement inside the modifier — parity with solc — instead of OtherExecutionError at the contract declaration. (At optimizer mode 3 the walk-back stops one line earlier, at the guard condition; the fixtures compile at hardhat-solx's -O1, where the statement line survives.)
🦋 Changeset detectedLatest commit: 58a940d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1577 +/- ##
==========================================
- Coverage 79.87% 75.72% -4.16%
==========================================
Files 452 453 +1
Lines 78781 79038 +257
Branches 78781 79038 +257
==========================================
- Hits 62924 59848 -3076
- Misses 13688 17128 +3440
+ Partials 2169 2062 -107 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
a4d67d2 to
b1de264
Compare
b1de264 to
a0d6af5
Compare
…declaration Since 0.1.6 solx emits DW_AT_call_line 0 for inlined subroutines whose call site is compiler-generated code — most importantly user functions inlined into the `__entry` dispatch. The decoder treated line 0 as unresolvable and dropped the range from inline_call_sites, which cost solx traces the callstack frame for the called function (solc renders that frame at the function's declaration line). Fall back to the abstract origin's decl_file/decl_line — the inlined function's own declaration — when call_file/call_line are absent or zero. DWARF gives us no decl_column, so the resolved offset sits at the line start; skip the indentation so the location lands on the declaration's first token, inside the function's AST span, where get_containing_function can name it. Proven red on the regenerated 0.1.6 fixture: the `unlucky` require of ValidatedCounter carried no inline call site for bumpIfValid at all.
…tom frame intermediate_frames seeded its duplicate-frame check with the raw failing function — the containing function of the reverting instruction's location. Under solx 0.1.6 line-0 emission that location is the decoder's Pass-3 synthesized function declaration, so for a revert inside a flattened modifier the seed named the modified function while the bottom frame actually renders under the modifier (via the revert walk-back) — and the called function's frame, just recovered from its line-0 dispatch call site, was dropped as a "duplicate". Result: solx traces lost the middle `Contract.function` frame that solc renders (sweep scenarios ModifierRevertTest / NestedModifierRevertTest went from 3 frames to 2 with solx 0.1.6). Seed the dedup with the bottom frame's already-resolved source reference instead, computing that entry before the intermediate frames at each call site. Pinned by a cross-contract golden on the StackTraceScenarios fixture: ValidatedCounterCaller -> ValidatedCounter keeps the bumpIfValid declaration frame between the caller frame and the modifier revert. Sweep validation (hardhat-solx, -O1): strict parity green on 0.1.6; still green on 0.1.4/0.1.5, so the change is inert for pre-line-0 artifacts.
a0d6af5 to
fb576c7
Compare
fb576c7 to
689ff85
Compare
…eferences The display-name unification (98308f2) mapped constructor/fallback/ receive to their keywords in function_start_source_reference too, but the hardhat-tests corpus pins the empty AST name for function-start frames — modifier-private-method renders receive()'s callstack entry with function "", and CI caught <receive> instead. The asymmetry between the two source-reference builders is deliberate; documented at the site so the next unification attempt reads why not. Verified against the corpus locally: the failing scenario passes on both compile profiles.
There was a problem hiding this comment.
Pull request overview
This PR updates EDR’s solx (DWARF) stack-trace attribution to handle declaration-attributed and line-0 debug locations introduced/relied on by solx 0.1.6, and adds new fixtures + integration tests to pin the corrected behavior (including optimizer-mode differences).
Changes:
- Update stack-trace inference strategy for solx to better attribute reverts/frames (declaration padding, unmapped reverts, line-0 DWARF call sites).
- Add/refresh solx fixtures (including new StackTraceScenarios + mode-3 variant) and add a CLI subcommand to regenerate supported fixtures in-repo.
- Expand provider-path integration tests and adjust parity-sweep pinning/docs based on improved solx parity.
Reviewed changes
Copilot reviewed 16 out of 19 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| js/integration-tests/solx-parity-sweep/test/sweep.ts | Removes previously pinned divergences now expected to be at parity. |
| js/integration-tests/solx-parity-sweep/README.md | Updates documentation around divergence pinning/toolchain dependence. |
| crates/tool/cli/src/update.rs | Improves overwrite behavior when output file doesn’t exist; fixes repo-root resolution depth. |
| crates/tool/cli/src/solx_fixtures.rs | Adds fixture regeneration implementation for solx outputs. |
| crates/tool/cli/src/main.rs | Exposes gen-solx-fixtures CLI command. |
| crates/edr_solidity/src/trace_strategy.rs | Extends per-compiler trace strategy hooks for solx-specific attribution recovery. |
| crates/edr_solidity/src/error_inferrer.rs | Integrates new strategy hooks; adds calldata decoding validation and step-PC thunking. |
| crates/edr_solidity/src/debug_info/dwarf.rs | Handles line-0 call sites by falling back to declaration location; adds decoder test. |
| crates/edr_solidity/fixtures/sources/StackTraceScenarios.sol | Adds new Solidity scenario contracts for provider-path stack-trace variants. |
| crates/edr_solidity/fixtures/solx_compiler_input*.json | Pins optimizer modes and adds new fixture input JSONs. |
| crates/edr_solidity/fixtures/solx_compiler_output*.json | Updates/regenerates solx outputs for 0.1.6 + adds new outputs. |
| crates/edr_solidity/fixtures/README.md | Documents regeneration paths for new fixture sets. |
| crates/edr_provider/tests/integration/solx_stack_trace.rs | Adds provider-path integration coverage for new scenarios and mode-3 variants. |
| .changeset/solx-declaration-attributed-inference.md | Adds a patch changeset describing user-visible stack-trace fixes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…g write The create branch wrote the caller's raw contents, so a Windows regen could commit CRLF into a brand-new fixture while every later update normalizes. Hoist the normalization above the read so both paths write the same bytes. (Raised by Copilot.)
popescuoctavian
left a comment
There was a problem hiding this comment.
LGTM overall! Left a few minor suggestions which are not blocking. Feel free to merge without requesting another review. Thanks!
| fs::write(path, &contents)?; | ||
| return Ok(()); | ||
| } | ||
| Err(error) => bail!("reading {}: {error}", path.display()), |
There was a problem hiding this comment.
Suggestion: use typed error instead of formatting it to a message (i.e. add message to context - Err(error) => Err(error).context(format!("reading {}", path.display()))?)
| { | ||
| let bottom_source_reference = bottom_entry | ||
| .source_reference() | ||
| .expect("CustomError always carries a source reference"); |
There was a problem hiding this comment.
Suggestion: the ErrorInferrer's convention is to use InferrerError:InvariantViolation for errors that that are are not locally-provable. I believe the same can be applied here for consistency.
There was a problem hiding this comment.
Ah thanks for the heads up, applied!
| /// Lazily yields the trace's EVM step PCs in execution order. A thunk | ||
| /// because trace steps are generic over the halt-reason type, which can't | ||
| /// cross the object-safe trait boundary. | ||
| pub type StepPcs<'a> = &'a dyn Fn() -> Vec<u32>; |
There was a problem hiding this comment.
Suggestion: the type alias hides the reference. Including & in the alias means that use sites no longer read as borrows. I'd avoid this by removing the reference from the alias and adding it to the use sites to make the borrow visible (&StepPcs).
There was a problem hiding this comment.
Cool, thanks for the suggestion. Applied!
…ource references The two bottom-frame source references introduced here were asserted with expect; the invariant holds but is only provable in solidity_stack_trace.rs, so it follows the inferrer's convention for non-locally-provable invariants instead.
The alias hid the borrow, so the use sites read as by-value. The lifetime parameter stays on the alias as the trait object's bound — without it the object defaults to 'static, which the step-borrowing closures don't satisfy.
bail! flattened it into a string, dropping the error kind and diverging from the with_context idiom used by solx_fixtures.rs and the solidity tool.
…io failures update()'s writes and the fixture-input read propagated io::Errors with a bare `?`, and io::Error's Display carries no path — a read-only fixture or a missing input reported "Permission denied (os error 13)" with nothing identifying the file. The update() call also gains the fixture name, since generate()'s loop adds no context of its own.
Adding test coverage for stack trace tests, along with a test fixture regen within edr_tool_cli. I will follow-up after hardhat-solx is published as this gives multiple opportunities to simplify/merge the infrastructure and add it to CI.
This PR notable bumps the used version of solx from 0.1.4 to 0.1.6.
Fixes for solx -> stack trace mappings (as part of 0.1.4 -> 0.1.6):
Further test coverage in #1552. Note that due to the nature of the fixtures I pre-added all scenarios to
StackTraceScenarios.solincluding the tests I saved for #1552.Added test cases for
-O3in specific, as DWARF mapping are slightly worse there. This likely due to underlying solx optimisations, but I thought it would be good to have as a red-green test, as the original tests (by Marian) were using-O3.Claude summary
Fixes the solx (DWARF) stack-trace attribution gaps found while broadening provider-path coverage, and adapts the DWARF consumption to solx 0.1.6's line-0 emission — including a frame-loss regression the parity sweep caught on 0.1.6 that would otherwise have shipped. The fixtures move to solx 0.1.6 and become regenerable in-repo, with a mode-3 variant keeping every compat path red-provable.
Split out of #1552, which now stacks the broad provider-path test coverage on top of this.
Root cause
solx attributes compiler-generated helper code — calldata decoding, shared revert builders — to the declaration of the enclosing function or contract in its DWARF line table, where solc leaves the same code unmapped in source maps. Since 0.1.6 solx emits line 0 for that code instead (solx#582), which the decoder's Pass-3 fallback turns back into the enclosing function's declaration — so the declaration-attributed paths below are the permanent consumer architecture, not a pre-0.1.6 shim. At hardhat-solx's default
-O1, solx 0.1.6 statement-attributes most of this code directly; other optimizer modes and older releases still produce the declaration-attributed and unmapped shapes.Three location-anchored inference paths broke on that difference:
fails_right_after_call/is_call_failed_errorcompare every post-CALL step location to the call site by strict equality. Under solc, unmapped helper instructions are skipped; under solx, the same instructions carry declaration-level locations (whose range merely contains the statement), so the heuristics bailed and the failure misclassified as a generic revert at the call site.requirewas reported at the function signature. The statement's real line is present in the line table — on the message-building instructions executed just before the revert — so this is recoverable EDR-side.revert()compiles to a shared helper that is entirely unmapped, and its return data is empty, so every revert heuristic missed and inference degraded toOtherExecutionErrorat the contract declaration.Both fixes for (1) and (2) live in the solx strategy;
SolcTraceStrategyis behavior-identical (strict equality kept, new parameters ignored):TraceStrategy::step_still_at_statement(step, statement): solx treats a step location that contains the statement (declaration-level padding) as "still at the statement"; solc keeps==. Used byis_last_location.TraceStrategy::revert_source_referencereceives a lazystep_pcsthunk (same pattern asPanicHelperContext). When the reverting instruction sits on the function's declaration line,SolxTraceStrategywalks the executed steps back to the last statement-level location.For (3), the revert branch falls back to a strategy hook when the reverting instruction has no resolvable containing function:
failing_function_from_calldataresolves the called function from the calldata selector under solx (solc returnsNone, behavior-identical), gated toREVERTopcodes and to calldata that actually decodes for the resolved function, so dispatch-level reverts still classify asInvalidParamsError.revert_source_referencetakes the location as anOption— unmapped gets the same walk-back recovery as declaration padding, with the function start as last resort.Review hardening of the walk-back
A review round produced three constraints (no existing golden changed): only statements of the failing function or of a modifier executed in the frame qualify — a statement of any other function ends the walk (previously the first resolvable location won, even the dispatcher's or an earlier internal call's); declaration matching requires source-file identity instead of bare line numbers; and the contract-declaration line counts as declaration-level padding alongside the function's. The
step_still_at_statementcontainment proxy is deliberately kept as-is: it can only over-match when every instruction of a later statement is declaration-attributed, and the parity sweep guards that residual. A later pass tried to also unify the constructor/fallback/receive display names between the two source-reference builders; the hardhat-tests corpus rejected it — function-start frames pin the raw (empty) AST name — so the asymmetry stays, documented at the site.solx 0.1.6 adaptation: the line-0 frame loss
solx 0.1.6 also emits
DW_AT_call_line 0for inlined subroutines whose call site is compiler-generated — most importantly user functions inlined into the__entrydispatch. That cost solx traces theContract.functioncallstack frame solc renders between the caller frame and the revert (sweep scenariosModifierRevertTest/NestedModifierRevertTestcollapsed from 3 frames to 2). Two-part fix:range_call_site_to_locationfalls back to the inlined function's owndecl_file/decl_linewhencall_file/call_lineare absent or zero — the same declaration line solc renders for that frame. (solx's DWARF carries nodecl_column— LLVM subprograms track only file and line — so the resolved offset skips the line's indentation to land inside the function's AST span.)intermediate_framespreviously deduped against the raw failing function — under Pass-3 synthesis that names the flattened-into function while the bottom frame actually renders under the modifier (via the walk-back), so the just-recovered frame was dropped as a "duplicate". It now dedups against the bottom frame's already-resolved source reference, computed before the intermediate frames at each call site.Effect on the traces
ReturndataSizeErrorsurfaces at the call site for both a returndata-size mismatch and a typed call to a codeless account, replacing a bare revert frame at the call site. This is parity with the solc route: solc ≥ 0.8.10 emits no EXTCODESIZE probe for returndata-expecting calls either, so both routes classify the EOA case as a returndata failure. (A trueNoncontractAccountCalledErrorwould need a void-returning call scenario — future coverage.)require's line instead of the declaration line — removing the provider-level twin of the sweep's pinnedNestedModifierRevertTestdivergence.revert()reports therevert()statement inside the modifier, matching solc. At optimizer mode 3 — where the entry path carries no statement line — the walk-back lands one line earlier, on the guard condition, instead of collapsing toOtherExecutionErrorat the contract declaration; the remaining one-line gap is the solx-side residual.The
solx_stack_trace.rsdiff is deliberately additive — the existing tests and helpers are untouched (the one deletion is an import line), so what shows up there is what changed semantically. Deduplicating those helpers happens in the stacked coverage PR, where the breadth of tests motivates it.Includes a changeset (patch), following #1569's precedent for user-visible stack-trace fixes.
Test provenance
All eight new provider goldens (plus the red-green decoder unit test for the line-0 call site) fail on
main. The-O1fixtures can no longer reproduce the declaration-attribution symptoms — solx 0.1.6 statement-attributes the default pipeline — so those tests pin the parity shape, while the mode-3 fixture variant pins the compat inference paths. Each mechanism was verified load-bearing by disabling it individually:main)mode3_bare_modifier_revert_recovers_the_failing_function(OtherExecutionErrorat the contract declaration)mode3_nested…/mode3_cross_contract…(revert at the declaration line instead of the failingrequire)step_still_at_statementcontainmentcross_contract…(dropped callee frame) + the decoder unit testTrace-shape assertions go through
assert_trace_shape, which prints one line per entry: a gained frame may be an improvement (update the pin), a lost frame is a regression.Fixtures: solx 0.1.6 +
gen-solx-fixturescargo run -p edr_tool_cli -- gen-solx-fixtures <solx-binary>regenerates the self-contained fixture outputs (counter,stack_trace_scenarios,stack_trace_scenarios_mode3): it splices thefixtures/sources/contents into the committed input JSON (whosecontentfields are deliberately empty), runssolx --standard-json, and writes solx's verbatim output. It lives next togen-execution-api— the repo's existing home for generators of committed artifacts. That surfaced two latent bugs fixed here:update::project_root()still assumed the crate's pre-#1139crates/tools/depth, so it resolved tocrates/instead of the repo root; andupdate()failed with a pathless ENOENT on a not-yet-committed output file instead of creating it.The inputs pin
settings.optimizer.modeexplicitly (trace shapes differ per mode) at hardhat-solx's-O1, the pipeline these fixtures stand in for — not solx's own default. The mode-3 variant ofstack_trace_scenariosexists because-O1DWARF is statement-attributed since 0.1.6: without it, the walk-back, declaration-attribution, and selector-recovery paths would be live code with no reachable input in the repo — they guard non-default modes and pre-0.1.6 artifacts. Re-running the subcommand against the committed tree yields zero diff, so the fixtures round-trip deterministically.Everything is regenerated with solx 0.1.6 (released 2026-07-22): the debug info changed for every contract, deployed bytecode is identical modulo the metadata hash, and every pre-existing golden holds. Two PC-anchored decoder tests moved: solx#583 resolves inline-assembly source refs to Yul AST nodes, so the assembly
revertandinvalid()now report solc's statement lines instead of the function-decl fall-back.The
scenariosfixture is outsidegen-solx-fixtures' reach: its input also names forge-std sources whose contents are scrubbed from the committed JSON, so it regenerates from the sweep project instead —pnpm regen-fixtures, the flow #1572 added — which is why it moves in its own step here. That also makes it the one fixture built by the real hardhat-solx pipeline rather than a baresolx --standard-json.New
StackTraceScenarios.solfixture: plain contracts, no forge-std, sogen-solx-fixturescan rebuild it from the repo alone. It is committed whole —GuardedBareRevert,ValidatedCounter(Caller)andExpectsWordpin the fixes here, the dispatch-level contracts are consumed by the stacked coverage PR. Splitting the file would move the line numbers every golden pins.Sweep pins
BareModifierRevertTestreaches parity with the bare-revert fix;InlineAssemblyRevertTestandInvalidOpcodeTestwith solx#583. OnlyInternalRecurseTest(optimizer unrolls 3-deep self-recursion) stays pinned. The pin set now assumes hardhat-solx maps0.8.34to solx 0.1.6.Verification
solx_stack_trace.rs: 11/11 (3 pre-existing goldens unchanged across the regeneration, 5-O1+ 3 mode-3 new; all 8 new ones proven red onmain, plus the per-mechanism disable checks above).edr_solidityunit tests: 80/80;edr_providerharness: 54 lib + 191 integration tests green at the tip (fork tests skip withoutALCHEMY_URL).-O1, local plugin build): 0.1.6 + these fixes → strict parity green; 0.1.4 and 0.1.5 + these fixes → still green, so the changes are inert for pre-line-0 artifacts.