Skip to content

Commit 792a919

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

File tree

6 files changed

+405
-2
lines changed

6 files changed

+405
-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: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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, Optional, 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+
class Sourcify(AbstractPlatform):
113+
"""
114+
Sourcify platform - fetches verified contracts from sourcify.dev
115+
"""
116+
117+
NAME = "Sourcify"
118+
PROJECT_URL = "https://sourcify.dev/"
119+
TYPE = Type.SOURCIFY
120+
121+
def compile(self, crytic_compile: "CryticCompile", **kwargs: str) -> None:
122+
"""Run the compilation by fetching from Sourcify.
123+
124+
Args:
125+
crytic_compile: Associated CryticCompile object
126+
**kwargs: Optional arguments. Used: "export_dir", "solc"
127+
128+
Raises:
129+
InvalidCompilation: If the contract is not found or API request fails
130+
"""
131+
target = self._target
132+
133+
# Parse target: sourcify-<chainId>:0x<address>
134+
match = re.match(r"^sourcify-(0x[a-fA-F0-9]+|\d+):(0x[a-fA-F0-9]{40})$", target)
135+
if not match:
136+
raise InvalidCompilation(f"Invalid Sourcify target format: {target}")
137+
138+
chain_id_raw, addr = match.groups()
139+
chain_id = _parse_chain_id(chain_id_raw)
140+
141+
# Build API URL - only fetch fields we need (match is always implicit)
142+
fields = ",".join(
143+
[
144+
"sources",
145+
"compilation.compilerVersion",
146+
"compilation.compilerSettings",
147+
"compilation.name",
148+
]
149+
)
150+
url = f"{SOURCIFY_API_BASE}/{chain_id}/{addr}?fields={fields}"
151+
152+
export_dir = kwargs.get("export_dir", "crytic-export")
153+
export_dir = os.path.join(export_dir, "sourcify-contracts")
154+
155+
if not os.path.exists(export_dir):
156+
os.makedirs(export_dir)
157+
158+
# Fetch from Sourcify API
159+
LOGGER.info("Fetching contract from Sourcify: chain=%s address=%s", chain_id, addr)
160+
161+
try:
162+
req = urllib.request.Request(url, headers={"User-Agent": _get_user_agent()})
163+
with urllib.request.urlopen(req) as response:
164+
data = json.loads(response.read().decode("utf-8"))
165+
except HTTPError as e:
166+
if e.code == 404:
167+
raise InvalidCompilation(
168+
f"Contract not verified on Sourcify: chain={chain_id} address={addr}"
169+
) from e
170+
raise InvalidCompilation(f"Sourcify API error: {e}") from e
171+
except URLError as e:
172+
raise InvalidCompilation(f"Failed to fetch from Sourcify: {e}") from e
173+
174+
# Log match type
175+
match_type = data.get("match", "unknown")
176+
match_messages = {
177+
"exact_match": "exact match found",
178+
"match": "partial match found (metadata may differ)",
179+
}
180+
if match_type in match_messages:
181+
LOGGER.info("Sourcify: %s", match_messages[match_type])
182+
else:
183+
LOGGER.warning("Sourcify: unexpected match type: %s", match_type)
184+
185+
# Extract sources and write to disk
186+
sources = data.get("sources", {})
187+
if not sources:
188+
raise InvalidCompilation("No source files returned from Sourcify")
189+
190+
working_dir, filenames = _write_source_files(sources, addr, chain_id, export_dir)
191+
192+
# Extract compilation settings
193+
compilation = data.get("compilation", {})
194+
compiler_version_str = compilation.get("compilerVersion", "")
195+
196+
# Parse compiler version (e.g., "0.8.7+commit.e28d00a7" -> "0.8.7")
197+
version_match = re.search(r"(\d+\.\d+\.\d+)", compiler_version_str)
198+
if not version_match:
199+
raise InvalidCompilation(
200+
f"Could not parse compiler version from: {compiler_version_str}"
201+
)
202+
compiler_version = version_match.group(1)
203+
204+
settings = compilation.get("compilerSettings", {})
205+
optimizer = settings.get("optimizer", {})
206+
optimization_used = optimizer.get("enabled", False)
207+
optimize_runs = optimizer.get("runs") if optimization_used else None
208+
evm_version = settings.get("evmVersion")
209+
via_ir = settings.get("viaIR", None)
210+
remappings = settings.get("remappings", [])
211+
212+
# Sanitize remappings
213+
remappings = _sanitize_remappings(remappings, working_dir) if remappings else None
214+
215+
# Get contract name from compilation metadata
216+
contract_name = compilation.get("name", "Contract")
217+
218+
# Create compilation unit
219+
compilation_unit = CompilationUnit(crytic_compile, contract_name)
220+
221+
compilation_unit.compiler_version = CompilerVersion(
222+
compiler=kwargs.get("solc", "solc"),
223+
version=compiler_version,
224+
optimized=optimization_used,
225+
optimize_runs=optimize_runs,
226+
)
227+
compilation_unit.compiler_version.look_for_installed_version()
228+
229+
# Use standalone_compile to get full compilation output including AST
230+
solc_standard_json.standalone_compile(
231+
filenames,
232+
compilation_unit,
233+
working_dir=working_dir,
234+
remappings=remappings,
235+
evm_version=evm_version,
236+
via_ir=via_ir,
237+
)
238+
239+
# Build solc args for config file
240+
solc_args: List[str] = []
241+
if via_ir:
242+
solc_args.append("--via-ir")
243+
if optimization_used:
244+
solc_args.append(f"--optimize --optimize-runs {optimize_runs}")
245+
if evm_version:
246+
solc_args.append(f"--evm-version {evm_version}")
247+
248+
# Write config file for reference (same format as Etherscan)
249+
metadata_config: Dict[str, Any] = {
250+
"solc_remaps": remappings or {},
251+
"solc_solcs_select": compiler_version,
252+
"solc_args": " ".join(solc_args),
253+
}
254+
255+
config_path = os.path.join(working_dir, "crytic_compile.config.json")
256+
with open(config_path, "w", encoding="utf-8") as f:
257+
json.dump(metadata_config, f)
258+
259+
def clean(self, **_kwargs: str) -> None:
260+
"""Clean compilation artifacts (no-op for Sourcify)."""
261+
pass
262+
263+
@staticmethod
264+
def is_supported(target: str, **kwargs: str) -> bool:
265+
"""Check if the target is a Sourcify target.
266+
267+
Args:
268+
target: Path to the target
269+
**kwargs: Optional arguments (unused)
270+
271+
Returns:
272+
bool: True if the target is a Sourcify target
273+
"""
274+
# Match sourcify-<chainId>:0x<address> where chainId is decimal or 0x hex
275+
return bool(re.match(r"^sourcify-(0x[a-fA-F0-9]+|\d+):0x[a-fA-F0-9]{40}$", target))
276+
277+
def is_dependency(self, _path: str) -> bool:
278+
"""Check if the path is a dependency.
279+
280+
Args:
281+
_path: Path to the target
282+
283+
Returns:
284+
bool: Always False for Sourcify
285+
"""
286+
return False
287+
288+
def _guessed_tests(self) -> List[str]:
289+
"""Guess the potential unit tests commands.
290+
291+
Returns:
292+
List[str]: Empty list (no tests for remote contracts)
293+
"""
294+
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)