Skip to content

Commit e13a126

Browse files
Fix Foundry source map index mismatch in sourceList export
When processing Foundry/Hardhat build-info, the sourceList was generated in JSON iteration order rather than by source ID. This caused bytecode source maps to reference wrong files when Echidna/Slither processed coverage data. Changes: - Add source_id_to_filename mapping to CompilationUnit - Sort sources by ID when parsing build-info - Store source ID mapping during parsing - Use filenames_for_export in solc export to maintain correct indices Fixes source map index mismatch where e.g. sourceList[185] would point to the wrong file because sources were not indexed by their compiler- assigned IDs.
1 parent c4a2480 commit e13a126

File tree

3 files changed

+66
-3
lines changed

3 files changed

+66
-3
lines changed

crytic_compile/compilation_unit.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ def __init__(self, crytic_compile: "CryticCompile", unique_id: str):
4040
# set containing all the filenames of this compilation unit
4141
self._filenames: list[Filename] = []
4242

43+
# mapping from source ID to filename (for Foundry/Hardhat source map compatibility)
44+
# When set, this takes precedence over _filenames for export ordering
45+
self._source_id_to_filename: dict[int, Filename] = {}
46+
4347
# mapping from absolute/relative/used to filename
4448
self._filenames_lookup: dict[str, Filename] | None = None
4549

@@ -181,6 +185,53 @@ def filenames(self, all_filenames: list[Filename]) -> None:
181185
"""
182186
self._filenames = all_filenames
183187

188+
@property
189+
def filenames_for_export(self) -> list[Filename]:
190+
"""Return filenames in the correct order for export (matching source map indices).
191+
192+
If source ID mapping is available (from Foundry/Hardhat build-info), returns
193+
filenames ordered by source ID. Otherwise, returns filenames in append order.
194+
195+
Returns:
196+
list[Filename]: Filenames ordered for export
197+
"""
198+
if not self._source_id_to_filename:
199+
return self._filenames
200+
201+
# Build list indexed by source ID
202+
max_id = max(self._source_id_to_filename.keys())
203+
result: list[Filename | None] = [None] * (max_id + 1)
204+
205+
for source_id, filename in self._source_id_to_filename.items():
206+
result[source_id] = filename
207+
208+
# Fill gaps with filenames from _filenames that aren't in the mapping
209+
mapped_filenames = set(self._source_id_to_filename.values())
210+
unmapped = [f for f in self._filenames if f not in mapped_filenames]
211+
unmapped_iter = iter(unmapped)
212+
213+
for i, entry in enumerate(result):
214+
if entry is None:
215+
try:
216+
result[i] = next(unmapped_iter)
217+
except StopIteration:
218+
# No more unmapped filenames, leave as None (will be filtered)
219+
pass
220+
221+
# Filter out None entries and return
222+
return [f for f in result if f is not None]
223+
224+
def set_source_id(self, source_id: int, filename: Filename) -> None:
225+
"""Set the source ID for a filename.
226+
227+
This is used by Foundry/Hardhat parsers to maintain correct source map indices.
228+
229+
Args:
230+
source_id (int): The source ID from the build-info
231+
filename (Filename): The filename associated with this ID
232+
"""
233+
self._source_id_to_filename[source_id] = filename
234+
184235
@property
185236
def filename_to_contracts(self) -> dict[Filename, set[str]]:
186237
"""Return a dict mapping the filename to a list of contract declared

crytic_compile/platform/hardhat.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,14 @@ def hardhat_like_parsing(
9595
]
9696

9797
if "sources" in targets_json:
98-
for path, info in targets_json["sources"].items():
98+
# Sort sources by ID to ensure correct processing order
99+
sources_with_ids = [
100+
(path, info, info.get("id"))
101+
for path, info in targets_json["sources"].items()
102+
]
103+
sources_with_ids.sort(key=lambda x: x[2] if x[2] is not None else float("inf"))
104+
105+
for original_path, info, source_id in sources_with_ids:
99106
if skip_filename:
100107
path = convert_filename(
101108
target,
@@ -104,7 +111,7 @@ def hardhat_like_parsing(
104111
working_dir=working_dir,
105112
)
106113
else:
107-
path = process_hardhat_v3_filename(path)
114+
path = process_hardhat_v3_filename(original_path)
108115

109116
path = convert_filename(
110117
path,
@@ -120,6 +127,10 @@ def hardhat_like_parsing(
120127
f"AST not found for {path} in {build_info} directory"
121128
)
122129

130+
# Store source ID mapping for correct export ordering
131+
if source_id is not None:
132+
compilation_unit.set_source_id(source_id, path)
133+
123134
if "contracts" in targets_json:
124135
for original_filename, contracts_info in targets_json["contracts"].items():
125136
original_filename = process_hardhat_v3_filename(original_filename)

crytic_compile/platform/solc.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def export_to_solc_from_compilation_unit(
7676

7777
# Create additional informational objects.
7878
sources = {filename: {"AST": ast} for (filename, ast) in compilation_unit.asts.items()}
79-
source_list = [x.absolute for x in compilation_unit.filenames]
79+
# Use filenames_for_export to ensure correct source map index ordering
80+
source_list = [x.absolute for x in compilation_unit.filenames_for_export]
8081

8182
# Create our root object to contain the contracts and other information.
8283
output = {"sources": sources, "sourceList": source_list, "contracts": contracts}

0 commit comments

Comments
 (0)