Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: VVM injection, internal functions and variables #294

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
63910a4
WIP: VVM internal variables
DanielSchiavini Sep 2, 2024
99bf860
VVM internal functions via vyper wrapper
DanielSchiavini Sep 2, 2024
bae493b
VVM eval
DanielSchiavini Sep 2, 2024
2fd09c0
Self-review
DanielSchiavini Sep 2, 2024
1114804
Inline method
DanielSchiavini Sep 3, 2024
7418116
Use the new vvm version
DanielSchiavini Sep 6, 2024
2ce4436
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Sep 11, 2024
9a0cd05
Recursion, review comments
DanielSchiavini Sep 11, 2024
37cf4eb
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Sep 18, 2024
b375574
Use vvm from https://github.com/vyperlang/vvm/pull/26
DanielSchiavini Sep 18, 2024
99cb8e1
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Sep 23, 2024
8b778dd
Update vvm
DanielSchiavini Sep 23, 2024
eb4518f
Extract regex
DanielSchiavini Sep 23, 2024
8de1b7f
Review comments
DanielSchiavini Sep 26, 2024
4e0876f
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Sep 26, 2024
28e97cd
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Oct 1, 2024
7837fdb
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Oct 3, 2024
e101d0b
refactor: extract function
DanielSchiavini Oct 4, 2024
dcfe941
feat: cache all vvm compile calls
DanielSchiavini Oct 4, 2024
23ed6ec
fix: revert search path changes
DanielSchiavini Oct 7, 2024
f99a667
Merge branch 'master' of github.com:vyperlang/titanoboa into vvm-storage
DanielSchiavini Oct 9, 2024
a96aeb4
Merge branch 'master' into vvm-storage
DanielSchiavini Oct 15, 2024
42eca98
feat: implement function injection instead of eval
DanielSchiavini Oct 18, 2024
3f302ed
some refactor
charles-cooper Oct 19, 2024
da7467e
fix bytecode override
charles-cooper Oct 19, 2024
d6dad63
fix API regression
charles-cooper Oct 19, 2024
550b334
fix lint
charles-cooper Oct 21, 2024
ea4bccb
fix bad variable
charles-cooper Oct 21, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 302 additions & 31 deletions boa/contracts/vvm/vvm_contract.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,62 @@
import re
from functools import cached_property
from pathlib import Path
from typing import Optional

from boa.contracts.abi.abi_contract import ABIContractFactory, ABIFunction
from vyper.utils import method_id

from boa.contracts.abi.abi_contract import ABIContract, ABIContractFactory, ABIFunction
from boa.environment import Env
from boa.rpc import to_bytes
from boa.util import cached_vvm
from boa.util.abi import Address
from boa.util.eip5202 import generate_blueprint_bytecode

# TODO: maybe this doesn't detect release candidates
VERSION_RE = re.compile(r"\s*#\s*(pragma\s+version|@version)\s+(\d+\.\d+\.\d+)")


# TODO: maybe move this up to vvm?
def _detect_version(source_code: str):
res = VERSION_RE.findall(source_code)
if len(res) < 1:
return None
# TODO: handle len(res) > 1
return res[0][1]


class VVMDeployer:
class VVMDeployer(ABIContractFactory):
"""
A deployer that uses the Vyper Version Manager (VVM).
This allows deployment of contracts written in older versions of Vyper that
can interact with new versions using the ABI definition.
"""

def __init__(self, abi, bytecode, filename):
def __init__(
self,
name: str,
compiler_output: dict,
source_code: str,
vyper_version: str,
filename: Optional[str] = None,
):
"""
Initialize a VVMDeployer instance.
:param abi: The contract's ABI.
:param bytecode: The contract's bytecode.
:param name: The name of the contract.
:param compiler_output: The compiler output of the contract.
:param source_code: The source code of the contract.
:param vyper_version: The Vyper version used to compile the contract.
:param filename: The filename of the contract.
"""
self.abi = abi
self.bytecode = bytecode
self.filename = filename

@classmethod
def from_compiler_output(cls, compiler_output, filename):
abi = compiler_output["abi"]
bytecode_nibbles = compiler_output["bytecode"]
bytecode = bytes.fromhex(bytecode_nibbles.removeprefix("0x"))
return cls(abi, bytecode, filename)
super().__init__(name, compiler_output["abi"], filename)
self.compiler_output = compiler_output
self.source_code = source_code
self.vyper_version = vyper_version

@cached_property
def factory(self):
return ABIContractFactory.from_abi_dict(self.abi)
def bytecode(self):
return to_bytes(self.compiler_output["bytecode"])

@classmethod
def from_compiler_output(
cls,
compiler_output: dict,
source_code: str,
vyper_version: str,
filename: Optional[str] = None,
name: Optional[str] = None,
):
if name is None:
name = Path(filename).stem if filename is not None else "<VVMContract>"
return cls(name, compiler_output, source_code, vyper_version, filename)

@cached_property
def constructor(self):
Expand Down Expand Up @@ -97,5 +108,265 @@ def deploy_as_blueprint(self, env=None, blueprint_preamble=None, **kwargs):
def __call__(self, *args, **kwargs):
return self.deploy(*args, **kwargs)

def at(self, address):
return self.factory.at(address)
def at(self, address: Address | str) -> "VVMContract":
"""
Create an ABI contract object for a deployed contract at `address`.
"""
address = Address(address)
contract = VVMContract(
compiler_output=self.compiler_output,
source_code=self.source_code,
vyper_version=self.vyper_version,
name=self._name,
abi=self._abi,
functions=self.functions,
address=address,
filename=self.filename,
)
contract.env.register_contract(address, contract)
return contract


class VVMContract(ABIContract):
"""
A deployed contract compiled with vvm, which is called via ABI.
"""

def __init__(self, compiler_output, source_code, vyper_version, *args, **kwargs):
super().__init__(*args, **kwargs)
self.compiler_output = compiler_output
self.source_code = source_code
self.vyper_version = vyper_version

@cached_property
def bytecode(self):
return to_bytes(self.compiler_output["bytecode"])

@cached_property
def bytecode_runtime(self):
return to_bytes(self.compiler_output["bytecode_runtime"])

def eval(self, code, return_type=None):
"""
Evaluate a vyper statement in the context of this contract.
Note that the return_type is necessary to correctly decode the result.
WARNING: This is different from the vyper eval() function, which is able
DanielSchiavini marked this conversation as resolved.
Show resolved Hide resolved
to automatically detect the return type.
:param code: A vyper statement.
:param return_type: The return type of the statement evaluation.
:returns: The result of the statement evaluation.
"""
return VVMEval(code, self, return_type)()

@cached_property
def _storage(self):
"""
Allows access to the storage variables of the contract.
Note that this is quite slow, as it requires the complete contract to be
DanielSchiavini marked this conversation as resolved.
Show resolved Hide resolved
recompiled.
"""

def storage():
return None

for name, spec in self.compiler_output["layout"]["storage_layout"].items():
setattr(storage, name, VVMStorageVariable(name, spec, self))
return storage

@cached_property
def internal(self):
"""
Allows access to internal functions of the contract.
Note that this is quite slow, as it requires the complete contract to be
recompiled.
"""

def internal():
return None

result = cached_vvm.compile_source(
self.source_code, vyper_version=self.vyper_version, output_format="metadata"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we just get metadata to begin with (when generating VVMDeployer)?

also, note that metadata isn't available in all versions of vyper, and it is not guaranteed to be stable between releases. but this probably works for recent vyper versions (0.3.7-0.3.10)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth I would have no objection to restricting the scope of the VVM functionalities to 0.3.7+

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we just get metadata to begin with (when generating VVMDeployer)?

Can we do that without calling the compiler twice? I implemented it here so it's only done when necessary (note the cached_property)

also, note that metadata isn't available in all versions of Vyper

We'll show the error from the compiler, so it should be clear when not supported

not guaranteed to be stable between releases

If you way to get info about private functions that's stable please let me know

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we just get metadata to begin with (when generating VVMDeployer)?

Can we do that without calling the compiler twice? I implemented it here so it's only done when necessary (note the cached_property)

ah, maybe we should have added the option for multiple output formats in vvm

Copy link
Collaborator Author

@DanielSchiavini DanielSchiavini Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea, we should probably add "stabilize" metadata (which, i think we have mostly stopped making changes to it) and add to combined_json output.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds like a good idea, however that won't work for old vyper versions

)["function_info"]
for fn_name, meta in result.items():
if meta["visibility"] == "internal":
function = VVMInternalFunction(meta, self)
setattr(internal, function.name, function)
return internal


class _VVMInternal(ABIFunction):
"""
An ABI function that temporarily changes the bytecode at the contract's address.
Subclasses of this class are used to inject code into the contract via the
`source_code` property using the vvm, temporarily changing the bytecode
at the contract's address.
"""

@cached_property
def _override_bytecode(self) -> bytes:
assert isinstance(self.contract, VVMContract) # help mypy
source = "\n".join((self.contract.source_code, self.source_code))
compiled = cached_vvm.compile_source(
source, vyper_version=self.contract.vyper_version
)
return to_bytes(compiled["<stdin>"]["bytecode_runtime"])

@property
def source_code(self) -> str:
"""
Returns the source code an internal function.
Must be implemented in subclasses.
"""
raise NotImplementedError

def __call__(self, *args, **kwargs):
env = self.contract.env
assert isinstance(self.contract, VVMContract) # help mypy
env.set_code(self.contract.address, self._override_bytecode)
charles-cooper marked this conversation as resolved.
Show resolved Hide resolved
try:
return super().__call__(*args, **kwargs)
finally:
env.set_code(self.contract.address, self.contract.bytecode_runtime)


class VVMInternalFunction(_VVMInternal):
"""
An internal function that is made available via the `internal` namespace.
It will temporarily change the bytecode at the contract's address.
"""

def __init__(self, meta: dict, contract: VVMContract):
abi = {
"anonymous": False,
"inputs": [
{"name": arg_name, "type": arg_type}
for arg_name, arg_type in meta["positional_args"].items()
],
"outputs": (
[{"name": meta["name"], "type": meta["return_type"]}]
if meta["return_type"] != "None"
else []
),
"stateMutability": meta["mutability"],
"name": meta["name"],
"type": "function",
}
super().__init__(abi, contract.contract_name)
self.contract = contract

@cached_property
def method_id(self) -> bytes:
return method_id(f"__boa_internal_{self.name}__" + self.signature)

@cached_property
def source_code(self):
fn_args = ", ".join([arg["name"] for arg in self._abi["inputs"]])

return_sig = ""
fn_call = ""
if self.return_type:
return_sig = f" -> {self.return_type}"
fn_call = "return "

fn_call += f"self.{self.name}({fn_args})"
fn_sig = ", ".join(
f"{arg['name']}: {arg['type']}" for arg in self._abi["inputs"]
)
return f"""
@external
@payable
def __boa_internal_{self.name}__({fn_sig}){return_sig}:
{fn_call}
"""


class VVMStorageVariable(_VVMInternal):
"""
A storage variable that is made available via the `storage` namespace.
It will temporarily change the bytecode at the contract's address.
"""

def __init__(self, name, spec, contract):
inputs, output_type = _get_storage_variable_types(spec)
abi = {
"anonymous": False,
"inputs": inputs,
"outputs": [{"name": name, "type": output_type}],
"name": name,
"type": "function",
}
super().__init__(abi, contract.contract_name)
self.contract = contract

def get(self, *args):
return self.__call__(*args)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't really work the same way as VyperContract.get()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please explain

Copy link
Member

@charles-cooper charles-cooper Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in vyper_contract.StorageVar.get(), it takes no arguments, and instead iterates over touched storage slots to reconstruct a dict. the way it's done here, it exposes a getter which the user needs to provide arguments to

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am mildly ok with not requiring the API to be the same, but in that case we should probably call it something else, like get_value_at()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah OK, I thought that's how that worked, I might have mixed it up when implementing zksync plugin.
I don't think we can get all the key values via the ABI


@cached_property
def method_id(self) -> bytes:
return method_id(f"__boa_private_{self.name}__" + self.signature)

@cached_property
def source_code(self):
getter_call = "".join(f"[{i['name']}]" for i in self._abi["inputs"])
args_signature = ", ".join(
f"{i['name']}: {i['type']}" for i in self._abi["inputs"]
)
return f"""
@external
@payable
def __boa_private_{self.name}__({args_signature}) -> {self.return_type[0]}:
return self.{self.name}{getter_call}
"""


class VVMEval(_VVMInternal):
"""
A Vyper eval statement which can be used to evaluate vyper statements
via vvm-compiled contracts. This implementation has some drawbacks:
- It is very slow, as it requires the complete contract to be recompiled.
- It does not detect the return type, as it is currently not possible.
- It will temporarily change the bytecode at the contract's address.
"""

def __init__(self, code: str, contract: VVMContract, return_type: str = None):
abi = {
"anonymous": False,
"inputs": [],
"outputs": ([{"name": "eval", "type": return_type}] if return_type else []),
"name": "__boa_debug__",
"type": "function",
}
super().__init__(abi, contract.contract_name)
self.contract = contract
self.code = code

@cached_property
def source_code(self):
debug_body = self.code
return_sig = ""
if self.return_type:
return_sig = f"-> ({', '.join(self.return_type)})"
debug_body = f"return {self.code}"
return f"""
@external
@payable
def __boa_debug__() {return_sig}:
{debug_body}
"""


def _get_storage_variable_types(spec: dict) -> tuple[list[dict], str]:
"""
Get the types of a storage variable
:param spec: The storage variable specification.
:return: The types of the storage variable:
1. A list of dictionaries containing the input types.
2. The output type name.
"""
hashmap_regex = re.compile(r"^HashMap\[([^[]+), (.+)]$")
output_type = spec["type"]
inputs: list[dict] = []
while output_type.startswith("HashMap"):
key_type, output_type = hashmap_regex.match(output_type).groups() # type: ignore
inputs.append({"name": f"key{len(inputs)}", "type": key_type})
return inputs, output_type
Loading
Loading