Skip to content

Commit 1401fb4

Browse files
authored
Merge pull request #492 from crytic/feat/vyper-standard-json
feat: compile vyper 0.3.7 via standard json input
2 parents fa60e8d + bc69c9e commit 1401fb4

File tree

7 files changed

+326
-132
lines changed

7 files changed

+326
-132
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
fail-fast: false
2323
matrix:
2424
os: ["ubuntu-latest", "windows-2022"]
25-
type: ["brownie", "buidler", "dapp", "embark", "hardhat", "solc", "truffle", "waffle", "foundry", "standard"]
25+
type: ["brownie", "buidler", "dapp", "embark", "hardhat", "solc", "truffle", "waffle", "foundry", "standard", "vyper"]
2626
exclude:
2727
# Currently broken, tries to pull git:// which is blocked by GH
2828
- type: embark

crytic_compile/crytic_compile.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
artifact_path,
2222
)
2323
from crytic_compile.compilation_unit import CompilationUnit
24-
from crytic_compile.platform import all_platforms, solc_standard_json
24+
from crytic_compile.platform import all_platforms
25+
from crytic_compile.platform.solc_standard_json import SolcStandardJson
26+
from crytic_compile.platform.vyper import VyperStandardJson
2527
from crytic_compile.platform.abstract_platform import AbstractPlatform
2628
from crytic_compile.platform.all_export import PLATFORMS_EXPORT
2729
from crytic_compile.platform.solc import Solc
@@ -676,18 +678,14 @@ def compile_all(target: str, **kwargs: str) -> List[CryticCompile]:
676678
**kwargs: optional arguments. Used: "solc_standard_json"
677679
678680
Raises:
679-
ValueError: If the target could not be compiled
681+
NotImplementedError: If the target could not be compiled
680682
681683
Returns:
682684
List[CryticCompile]: Returns a list of CryticCompile instances for all compilations which occurred.
683685
"""
684686
use_solc_standard_json = kwargs.get("solc_standard_json", False)
685687

686-
# Attempt to perform glob expansion of target/filename
687-
globbed_targets = glob.glob(target, recursive=True)
688-
689688
# Check if the target refers to a valid target already.
690-
# If it does not, we assume it's a glob pattern.
691689
compilations: List[CryticCompile] = []
692690
if os.path.isfile(target) or is_supported(target):
693691
if target.endswith(".zip"):
@@ -699,28 +697,33 @@ def compile_all(target: str, **kwargs: str) -> List[CryticCompile]:
699697
compilations = load_from_zip(tmp.name)
700698
else:
701699
compilations.append(CryticCompile(target, **kwargs))
702-
elif os.path.isdir(target) or len(globbed_targets) > 0:
703-
# We create a new glob to find solidity files at this path (in case this is a directory)
704-
filenames = glob.glob(os.path.join(target, "*.sol"))
705-
if not filenames:
706-
filenames = glob.glob(os.path.join(target, "*.vy"))
707-
if not filenames:
708-
filenames = globbed_targets
709-
700+
elif os.path.isdir(target):
701+
solidity_filenames = glob.glob(os.path.join(target, "*.sol"))
702+
vyper_filenames = glob.glob(os.path.join(target, "*.vy"))
710703
# Determine if we're using --standard-solc option to
711704
# aggregate many files into a single compilation.
712705
if use_solc_standard_json:
713706
# If we're using standard solc, then we generated our
714707
# input to create a single compilation with all files
715-
standard_json = solc_standard_json.SolcStandardJson()
716-
for filename in filenames:
717-
standard_json.add_source_file(filename)
718-
compilations.append(CryticCompile(standard_json, **kwargs))
708+
solc_standard_json = SolcStandardJson()
709+
solc_standard_json.add_source_files(solidity_filenames)
710+
compilations.append(CryticCompile(solc_standard_json, **kwargs))
719711
else:
720712
# We compile each file and add it to our compilations.
721-
for filename in filenames:
713+
for filename in solidity_filenames:
722714
compilations.append(CryticCompile(filename, **kwargs))
715+
716+
if vyper_filenames:
717+
vyper_standard_json = VyperStandardJson()
718+
vyper_standard_json.add_source_files(vyper_filenames)
719+
compilations.append(CryticCompile(vyper_standard_json, **kwargs))
723720
else:
724-
raise ValueError(f"{str(target)} is not a file or directory.")
721+
raise NotImplementedError()
722+
# TODO split glob into language
723+
# # Attempt to perform glob expansion of target/filename
724+
# globbed_targets = glob.glob(target, recursive=True)
725+
# print(globbed_targets)
726+
727+
# raise ValueError(f"{str(target)} is not a file or directory.")
725728

726729
return compilations

crytic_compile/platform/all_platforms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
from .solc_standard_json import SolcStandardJson
1515
from .standard import Standard
1616
from .truffle import Truffle
17-
from .vyper import Vyper
17+
from .vyper import VyperStandardJson
1818
from .waffle import Waffle
1919
from .foundry import Foundry

crytic_compile/platform/solc_standard_json.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,15 @@ def add_source_file(self, file_path: str) -> None:
405405
"""
406406
add_source_file(self._json, file_path)
407407

408+
def add_source_files(self, files_path: List[str]) -> None:
409+
"""Append files
410+
411+
Args:
412+
files_path (List[str]): files to append
413+
"""
414+
for file_path in files_path:
415+
add_source_file(self._json, file_path)
416+
408417
def add_remapping(self, remapping: str) -> None:
409418
"""Append our remappings
410419

crytic_compile/platform/vyper.py

Lines changed: 100 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
LOGGER = logging.getLogger("CryticCompile")
2626

2727

28-
class Vyper(AbstractPlatform):
28+
class VyperStandardJson(AbstractPlatform):
2929
"""
3030
Vyper platform
3131
"""
@@ -34,6 +34,28 @@ class Vyper(AbstractPlatform):
3434
PROJECT_URL = "https://github.com/vyperlang/vyper"
3535
TYPE = Type.VYPER
3636

37+
def __init__(self, target: Optional[Path] = None, **_kwargs: str):
38+
super().__init__(str(target), **_kwargs)
39+
self.standard_json_input = {
40+
"language": "Vyper",
41+
"sources": {},
42+
"settings": {
43+
"outputSelection": {
44+
"*": {
45+
"*": [
46+
"abi",
47+
"devdoc",
48+
"userdoc",
49+
"evm.bytecode",
50+
"evm.deployedBytecode",
51+
"evm.deployedBytecode.sourceMap",
52+
],
53+
"": ["ast"],
54+
}
55+
}
56+
},
57+
}
58+
3759
def compile(self, crytic_compile: "CryticCompile", **kwargs: str) -> None:
3860
"""Compile the target
3961
@@ -44,46 +66,67 @@ def compile(self, crytic_compile: "CryticCompile", **kwargs: str) -> None:
4466
4567
"""
4668
target = self._target
69+
# If the target was a directory `add_source_file` should have been called
70+
# by `compile_all`. Otherwise, we should have a single file target.
71+
if self._target is not None and os.path.isfile(self._target):
72+
self.add_source_files([target])
4773

48-
vyper = kwargs.get("vyper", "vyper")
49-
50-
targets_json = _run_vyper(target, vyper)
74+
vyper_bin = kwargs.get("vyper", "vyper")
5175

52-
assert "version" in targets_json
76+
compilation_artifacts = _run_vyper_standard_json(self.standard_json_input, vyper_bin)
5377
compilation_unit = CompilationUnit(crytic_compile, str(target))
5478

79+
compiler_version = compilation_artifacts["compiler"].split("-")[1]
80+
if compiler_version != "0.3.7":
81+
LOGGER.info("Vyper != 0.3.7 support is a best effort and might fail")
5582
compilation_unit.compiler_version = CompilerVersion(
56-
compiler="vyper", version=targets_json["version"], optimized=False
83+
compiler="vyper", version=compiler_version, optimized=False
5784
)
5885

59-
assert target in targets_json
60-
61-
info = targets_json[target]
62-
filename = convert_filename(target, _relative_to_short, crytic_compile)
63-
64-
contract_name = Path(target).parts[-1]
86+
for source_file, contract_info in compilation_artifacts["contracts"].items():
87+
filename = convert_filename(source_file, _relative_to_short, crytic_compile)
88+
source_unit = compilation_unit.create_source_unit(filename)
89+
for contract_name, contract_metadata in contract_info.items():
90+
source_unit.add_contract_name(contract_name)
91+
compilation_unit.filename_to_contracts[filename].add(contract_name)
92+
93+
source_unit.abis[contract_name] = contract_metadata["abi"]
94+
source_unit.bytecodes_init[contract_name] = contract_metadata["evm"]["bytecode"][
95+
"object"
96+
].replace("0x", "")
97+
# Vyper does not provide the source mapping for the init bytecode
98+
source_unit.srcmaps_init[contract_name] = []
99+
source_unit.srcmaps_runtime[contract_name] = contract_metadata["evm"][
100+
"deployedBytecode"
101+
]["sourceMap"].split(";")
102+
source_unit.bytecodes_runtime[contract_name] = contract_metadata["evm"][
103+
"deployedBytecode"
104+
]["object"].replace("0x", "")
105+
source_unit.natspec[contract_name] = Natspec(
106+
contract_metadata["userdoc"], contract_metadata["devdoc"]
107+
)
108+
109+
for source_file, ast in compilation_artifacts["sources"].items():
110+
filename = convert_filename(source_file, _relative_to_short, crytic_compile)
111+
source_unit = compilation_unit.create_source_unit(filename)
112+
source_unit.ast = ast
113+
114+
def add_source_files(self, file_paths: List[str]) -> None:
115+
"""
116+
Append files
65117
66-
source_unit = compilation_unit.create_source_unit(filename)
118+
Args:
119+
file_paths (List[str]): files to append
67120
68-
source_unit.add_contract_name(contract_name)
69-
compilation_unit.filename_to_contracts[filename].add(contract_name)
70-
source_unit.abis[contract_name] = info["abi"]
71-
source_unit.bytecodes_init[contract_name] = info["bytecode"].replace("0x", "")
72-
source_unit.bytecodes_runtime[contract_name] = info["bytecode_runtime"].replace("0x", "")
73-
# Vyper does not provide the source mapping for the init bytecode
74-
source_unit.srcmaps_init[contract_name] = []
75-
# info["source_map"]["pc_pos_map"] contains the source mapping in a simpler format
76-
# However pc_pos_map_compressed" seems to follow solc's format, so for convenience
77-
# We store the same
78-
# TODO: create SourceMapping class, so that srcmaps_runtime would store an class
79-
# That will give more flexebility to different compilers
80-
source_unit.srcmaps_runtime[contract_name] = info["source_map"]["pc_pos_map_compressed"]
121+
Returns:
81122
82-
# Natspec not yet handled for vyper
83-
source_unit.natspec[contract_name] = Natspec({}, {})
123+
"""
84124

85-
ast = _get_vyper_ast(target, vyper)
86-
source_unit.ast = ast
125+
for file_path in file_paths:
126+
with open(file_path, "r", encoding="utf8") as f:
127+
self.standard_json_input["sources"][file_path] = { # type: ignore
128+
"content": f.read(),
129+
}
87130

88131
def clean(self, **_kwargs: str) -> None:
89132
"""Clean compilation artifacts
@@ -129,98 +172,45 @@ def _guessed_tests(self) -> List[str]:
129172
return []
130173

131174

132-
def _run_vyper(
133-
filename: str, vyper: str, env: Optional[Dict] = None, working_dir: Optional[str] = None
175+
def _run_vyper_standard_json(
176+
standard_json_input: Dict, vyper: str, env: Optional[Dict] = None
134177
) -> Dict:
135-
"""Run vyper
178+
"""Run vyper and write compilation output to a file
136179
137180
Args:
138-
filename (str): vyper file
181+
standard_json_input (Dict): Dict containing the vyper standard json input
139182
vyper (str): vyper binary
140183
env (Optional[Dict], optional): Environment variables. Defaults to None.
141-
working_dir (Optional[str], optional): Working directory. Defaults to None.
142184
143185
Raises:
144186
InvalidCompilation: If vyper failed to run
145187
146188
Returns:
147189
Dict: Vyper json compilation artifact
148190
"""
149-
if not os.path.isfile(filename):
150-
raise InvalidCompilation(f"{filename} does not exist (are you in the correct directory?)")
151-
152-
cmd = [vyper, filename, "-f", "combined_json"]
153-
154-
additional_kwargs: Dict = {"cwd": working_dir} if working_dir else {}
155-
stderr = ""
156-
LOGGER.info(
157-
"'%s' running",
158-
" ".join(cmd),
159-
)
160-
try:
161-
with subprocess.Popen(
162-
cmd,
163-
stdout=subprocess.PIPE,
164-
stderr=subprocess.PIPE,
165-
env=env,
166-
executable=shutil.which(cmd[0]),
167-
**additional_kwargs,
168-
) as process:
169-
stdout, stderr = process.communicate()
170-
res = stdout.split(b"\n")
171-
res = res[-2]
172-
return json.loads(res)
173-
except OSError as error:
174-
# pylint: disable=raise-missing-from
175-
raise InvalidCompilation(error)
176-
except json.decoder.JSONDecodeError:
177-
# pylint: disable=raise-missing-from
178-
raise InvalidCompilation(f"Invalid vyper compilation\n{stderr}")
179-
180-
181-
def _get_vyper_ast(
182-
filename: str, vyper: str, env: Optional[Dict] = None, working_dir: Optional[str] = None
183-
) -> Dict:
184-
"""Get ast from vyper
185-
186-
Args:
187-
filename (str): vyper file
188-
vyper (str): vyper binary
189-
env (Dict, optional): Environment variables. Defaults to None.
190-
working_dir (str, optional): Working directory. Defaults to None.
191-
192-
Raises:
193-
InvalidCompilation: If vyper failed to run
194-
195-
Returns:
196-
Dict: [description]
197-
"""
198-
if not os.path.isfile(filename):
199-
raise InvalidCompilation(f"{filename} does not exist (are you in the correct directory?)")
200-
201-
cmd = [vyper, filename, "-f", "ast"]
202-
203-
additional_kwargs: Dict = {"cwd": working_dir} if working_dir else {}
204-
stderr = ""
205-
try:
206-
with subprocess.Popen(
207-
cmd,
208-
stdout=subprocess.PIPE,
209-
stderr=subprocess.PIPE,
210-
env=env,
211-
executable=shutil.which(cmd[0]),
212-
**additional_kwargs,
213-
) as process:
214-
stdout, stderr = process.communicate()
215-
res = stdout.split(b"\n")
216-
res = res[-2]
217-
return json.loads(res)
218-
except json.decoder.JSONDecodeError:
219-
# pylint: disable=raise-missing-from
220-
raise InvalidCompilation(f"Invalid vyper compilation\n{stderr}")
221-
except Exception as exception:
222-
# pylint: disable=raise-missing-from
223-
raise InvalidCompilation(exception)
191+
cmd = [vyper, "--standard-json"]
192+
193+
with subprocess.Popen(
194+
cmd,
195+
stdin=subprocess.PIPE,
196+
stdout=subprocess.PIPE,
197+
stderr=subprocess.PIPE,
198+
env=env,
199+
executable=shutil.which(cmd[0]),
200+
) as process:
201+
202+
stdout_b, stderr_b = process.communicate(json.dumps(standard_json_input).encode("utf-8"))
203+
stdout, _stderr = (
204+
stdout_b.decode(),
205+
stderr_b.decode(errors="backslashreplace"),
206+
) # convert bytestrings to unicode strings
207+
208+
vyper_standard_output = json.loads(stdout)
209+
if "errors" in vyper_standard_output:
210+
# TODO format errors
211+
raise InvalidCompilation(vyper_standard_output["errors"])
212+
213+
return vyper_standard_output
224214

225215

226216
def _relative_to_short(relative: Path) -> Path:

scripts/ci_test_vyper.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
3+
### Test vyper integration
4+
5+
pip install vyper
6+
7+
echo "Testing vyper integration of $(realpath "$(which crytic-compile)")"
8+
9+
cd tests/vyper || exit 255
10+
11+
if ! crytic-compile auction.vy --export-formats standard
12+
then echo "vyper test failed" && exit 255
13+
else echo "vyper test passed" && exit 0
14+
fi

0 commit comments

Comments
 (0)