Skip to content

Commit c630132

Browse files
committed
Add support for Sourcify
Closes #634
1 parent 62ce55b commit c630132

File tree

6 files changed

+422
-2
lines changed

6 files changed

+422
-2
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
name: Etherscan
2+
name: Onchain
33

44
on:
55
push:
@@ -22,7 +22,7 @@ jobs:
2222
strategy:
2323
matrix:
2424
os: ["ubuntu-latest", "windows-2025"]
25-
type: ["etherscan"]
25+
type: ["etherscan", "sourcify"]
2626
steps:
2727
- uses: actions/checkout@v6
2828
- name: Set up shell

crytic_compile/platform/all_platforms.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
from .vyper import VyperStandardJson
1818
from .waffle import Waffle
1919
from .foundry import Foundry
20+
from .sourcify import Sourcify
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
"""
2+
Sourcify platform.
3+
4+
Fetches verified contract source code and compilation artifacts from the Sourcify API.
5+
"""
6+
7+
import json
8+
import logging
9+
import os
10+
import re
11+
import urllib.request
12+
from importlib.metadata import version
13+
from pathlib import Path, PurePosixPath
14+
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
15+
from urllib.error import HTTPError, URLError
16+
17+
from crytic_compile.compilation_unit import CompilationUnit
18+
from crytic_compile.compiler.compiler import CompilerVersion
19+
from crytic_compile.platform import solc_standard_json
20+
from crytic_compile.platform.abstract_platform import AbstractPlatform
21+
from crytic_compile.platform.etherscan import _sanitize_remappings
22+
from crytic_compile.platform.exceptions import InvalidCompilation
23+
from crytic_compile.platform.types import Type
24+
25+
if TYPE_CHECKING:
26+
from crytic_compile import CryticCompile
27+
28+
LOGGER = logging.getLogger("CryticCompile")
29+
30+
SOURCIFY_API_BASE = "https://sourcify.dev/server/v2/contract"
31+
32+
33+
def _get_user_agent() -> str:
34+
"""Get the User-Agent string with package version."""
35+
try:
36+
pkg_version = version("crytic-compile")
37+
except Exception: # pylint: disable=broad-except
38+
pkg_version = "unknown"
39+
return f"crytic-compile/{pkg_version}"
40+
41+
42+
def _parse_chain_id(chain_id_str: str) -> str:
43+
"""Convert hex or decimal chain ID to decimal string.
44+
45+
Args:
46+
chain_id_str: Chain ID as decimal string or hex string (0x prefix)
47+
48+
Returns:
49+
str: Chain ID as decimal string
50+
"""
51+
if chain_id_str.lower().startswith("0x"):
52+
return str(int(chain_id_str, 16))
53+
return chain_id_str
54+
55+
56+
def _write_source_files(
57+
sources: Dict[str, Dict[str, str]],
58+
addr: str,
59+
chain_id: str,
60+
export_dir: str,
61+
) -> Tuple[str, List[str]]:
62+
"""Write source files to disk.
63+
64+
Args:
65+
sources: Dict mapping filename to {"content": source_code}
66+
addr: Contract address
67+
chain_id: Chain ID
68+
export_dir: Base export directory
69+
70+
Returns:
71+
Tuple of (working_directory, list_of_filenames)
72+
73+
Raises:
74+
IOError: If a path would escape the allowed directory
75+
"""
76+
directory = os.path.join(export_dir, f"sourcify-{chain_id}-{addr}")
77+
78+
filenames: List[str] = []
79+
80+
for filename, source_info in sources.items():
81+
content = source_info.get("content", "")
82+
83+
path_filename = PurePosixPath(filename)
84+
85+
# Skip non-Solidity/Vyper files
86+
if path_filename.suffix not in (".sol", ".vy"):
87+
continue
88+
89+
# Handle absolute paths by making them relative
90+
if path_filename.is_absolute():
91+
path_filename = PurePosixPath(*path_filename.parts[1:])
92+
93+
filenames.append(path_filename.as_posix())
94+
path_filename_disk = Path(directory, path_filename)
95+
96+
# Security check: ensure path stays within allowed directory
97+
allowed_path = os.path.abspath(directory)
98+
if os.path.commonpath((allowed_path, os.path.abspath(path_filename_disk))) != allowed_path:
99+
raise IOError(
100+
f"Path '{path_filename_disk}' is outside of the allowed directory: {allowed_path}"
101+
)
102+
103+
if not os.path.exists(path_filename_disk.parent):
104+
os.makedirs(path_filename_disk.parent)
105+
106+
with open(path_filename_disk, "w", encoding="utf8") as file_desc:
107+
file_desc.write(content)
108+
109+
return directory, filenames
110+
111+
112+
def _fetch_sourcify_data(chain_id: str, addr: str) -> Dict[str, Any]:
113+
"""Fetch contract data from Sourcify API.
114+
115+
Args:
116+
chain_id: Chain ID (decimal string)
117+
addr: Contract address
118+
119+
Returns:
120+
Dict containing sources and compilation data
121+
122+
Raises:
123+
InvalidCompilation: If the contract is not found or API request fails
124+
"""
125+
fields = ",".join(
126+
[
127+
"sources",
128+
"compilation.compilerVersion",
129+
"compilation.compilerSettings",
130+
"compilation.name",
131+
]
132+
)
133+
url = f"{SOURCIFY_API_BASE}/{chain_id}/{addr}?fields={fields}"
134+
135+
LOGGER.info("Fetching contract from Sourcify: chain=%s address=%s", chain_id, addr)
136+
137+
try:
138+
req = urllib.request.Request(url, headers={"User-Agent": _get_user_agent()})
139+
with urllib.request.urlopen(req) as response:
140+
data = json.loads(response.read().decode("utf-8"))
141+
except HTTPError as e:
142+
if e.code == 404:
143+
raise InvalidCompilation(
144+
f"Contract not verified on Sourcify: chain={chain_id} address={addr}"
145+
) from e
146+
raise InvalidCompilation(f"Sourcify API error: {e}") from e
147+
except URLError as e:
148+
raise InvalidCompilation(f"Failed to fetch from Sourcify: {e}") from e
149+
150+
# Log match type
151+
match_type = data.get("match", "unknown")
152+
match_messages = {
153+
"exact_match": "exact match found",
154+
"match": "partial match found (metadata may differ)",
155+
}
156+
if match_type in match_messages:
157+
LOGGER.info("Sourcify: %s", match_messages[match_type])
158+
else:
159+
LOGGER.warning("Sourcify: unexpected match type: %s", match_type)
160+
161+
return data
162+
163+
164+
def _write_config_file(working_dir: str, compiler_version: str, settings: Dict[str, Any]) -> None:
165+
"""Write crytic_compile.config.json file.
166+
167+
Args:
168+
working_dir: Directory to write config to
169+
compiler_version: Solc version string
170+
settings: Compiler settings from Sourcify
171+
"""
172+
optimizer = settings.get("optimizer", {})
173+
optimization_used = optimizer.get("enabled", False)
174+
optimize_runs = optimizer.get("runs") if optimization_used else None
175+
evm_version = settings.get("evmVersion")
176+
via_ir = settings.get("viaIR")
177+
remappings = settings.get("remappings", [])
178+
179+
solc_args: List[str] = []
180+
if via_ir:
181+
solc_args.append("--via-ir")
182+
if optimization_used:
183+
solc_args.append(f"--optimize --optimize-runs {optimize_runs}")
184+
if evm_version:
185+
solc_args.append(f"--evm-version {evm_version}")
186+
187+
metadata_config: Dict[str, Any] = {
188+
"solc_remaps": _sanitize_remappings(remappings, working_dir) if remappings else {},
189+
"solc_solcs_select": compiler_version,
190+
"solc_args": " ".join(solc_args),
191+
}
192+
193+
config_path = os.path.join(working_dir, "crytic_compile.config.json")
194+
with open(config_path, "w", encoding="utf-8") as f:
195+
json.dump(metadata_config, f)
196+
197+
198+
class Sourcify(AbstractPlatform):
199+
"""
200+
Sourcify platform - fetches verified contracts from sourcify.dev
201+
"""
202+
203+
NAME = "Sourcify"
204+
PROJECT_URL = "https://sourcify.dev/"
205+
TYPE = Type.SOURCIFY
206+
207+
def compile( # pylint: disable=too-many-locals
208+
self, crytic_compile: "CryticCompile", **kwargs: str
209+
) -> None:
210+
"""Run the compilation by fetching from Sourcify.
211+
212+
Args:
213+
crytic_compile: Associated CryticCompile object
214+
**kwargs: Optional arguments. Used: "export_dir", "solc"
215+
216+
Raises:
217+
InvalidCompilation: If the contract is not found or API request fails
218+
"""
219+
# Parse target: sourcify-<chainId>:0x<address>
220+
match = re.match(r"^sourcify-(0x[a-fA-F0-9]+|\d+):(0x[a-fA-F0-9]{40})$", self._target)
221+
if not match:
222+
raise InvalidCompilation(f"Invalid Sourcify target format: {self._target}")
223+
224+
chain_id = _parse_chain_id(match.group(1))
225+
addr = match.group(2)
226+
227+
# Prepare export directory
228+
export_dir = os.path.join(kwargs.get("export_dir", "crytic-export"), "sourcify-contracts")
229+
if not os.path.exists(export_dir):
230+
os.makedirs(export_dir)
231+
232+
# Fetch from Sourcify API
233+
data = _fetch_sourcify_data(chain_id, addr)
234+
235+
sources = data.get("sources", {})
236+
if not sources:
237+
raise InvalidCompilation("No source files returned from Sourcify")
238+
239+
working_dir, filenames = _write_source_files(sources, addr, chain_id, export_dir)
240+
241+
# Extract compilation settings
242+
compilation = data.get("compilation", {})
243+
compiler_version_str = compilation.get("compilerVersion", "")
244+
version_match = re.search(r"(\d+\.\d+\.\d+)", compiler_version_str)
245+
if not version_match:
246+
raise InvalidCompilation(
247+
f"Could not parse compiler version from: {compiler_version_str}"
248+
)
249+
compiler_version = version_match.group(1)
250+
251+
settings = compilation.get("compilerSettings", {})
252+
optimizer = settings.get("optimizer", {})
253+
optimization_used = optimizer.get("enabled", False)
254+
remappings = _sanitize_remappings(settings.get("remappings", []), working_dir) or None
255+
256+
# Create and configure compilation unit
257+
compilation_unit = CompilationUnit(crytic_compile, compilation.get("name", "Contract"))
258+
compilation_unit.compiler_version = CompilerVersion(
259+
compiler=kwargs.get("solc", "solc"),
260+
version=compiler_version,
261+
optimized=optimization_used,
262+
optimize_runs=optimizer.get("runs") if optimization_used else None,
263+
)
264+
compilation_unit.compiler_version.look_for_installed_version()
265+
266+
solc_standard_json.standalone_compile(
267+
filenames,
268+
compilation_unit,
269+
working_dir=working_dir,
270+
remappings=remappings,
271+
evm_version=settings.get("evmVersion"),
272+
via_ir=settings.get("viaIR"),
273+
)
274+
275+
_write_config_file(working_dir, compiler_version, settings)
276+
277+
def clean(self, **_kwargs: str) -> None:
278+
"""Clean compilation artifacts (no-op for Sourcify)."""
279+
280+
@staticmethod
281+
def is_supported(target: str, **kwargs: str) -> bool:
282+
"""Check if the target is a Sourcify target.
283+
284+
Args:
285+
target: Path to the target
286+
**kwargs: Optional arguments (unused)
287+
288+
Returns:
289+
bool: True if the target is a Sourcify target
290+
"""
291+
# Match sourcify-<chainId>:0x<address> where chainId is decimal or 0x hex
292+
return bool(re.match(r"^sourcify-(0x[a-fA-F0-9]+|\d+):0x[a-fA-F0-9]{40}$", target))
293+
294+
def is_dependency(self, _path: str) -> bool:
295+
"""Check if the path is a dependency.
296+
297+
Args:
298+
_path: Path to the target
299+
300+
Returns:
301+
bool: Always False for Sourcify
302+
"""
303+
return False
304+
305+
def _guessed_tests(self) -> List[str]:
306+
"""Guess the potential unit tests commands.
307+
308+
Returns:
309+
List[str]: Empty list (no tests for remote contracts)
310+
"""
311+
return []

crytic_compile/platform/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Type(IntEnum):
2424
BUILDER = 11
2525
HARDHAT = 11
2626
FOUNDRY = 12
27+
SOURCIFY = 13
2728

2829
STANDARD = 100
2930
ARCHIVE = 101
@@ -65,6 +66,8 @@ def __str__(self) -> str: # pylint: disable=too-many-branches
6566
return "Browner"
6667
if self == Type.FOUNDRY:
6768
return "Foundry"
69+
if self == Type.SOURCIFY:
70+
return "Sourcify"
6871
raise ValueError
6972

7073
def priority(self) -> int:

crytic_compile/platform/types.pyc

-363 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)