diff --git a/pyproject.toml b/pyproject.toml index 2aaed5a751..774a102d64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ markers = "fuzzing: Run Hypothesis fuzz test suite" line_length = 100 force_grid_wrap = 0 include_trailing_comma = true -known_third_party = ["IPython", "click", "dataclassy", "eth_account", "eth_utils", "github", "hypothesis", "hypothesis_jsonschema", "importlib_metadata", "pluggy", "pytest", "requests", "setuptools", "web3", "yaml"] +known_third_party = ["IPython", "click", "dataclassy", "eth_abi", "eth_account", "eth_utils", "github", "hexbytes", "hypothesis", "hypothesis_jsonschema", "importlib_metadata", "pluggy", "pytest", "requests", "setuptools", "web3", "yaml"] known_first_party = ["ape_accounts", "ape"] multi_line_output = 3 use_parentheses = true diff --git a/src/ape/__init__.py b/src/ape/__init__.py index 7211d31ba5..663bb1cdba 100644 --- a/src/ape/__init__.py +++ b/src/ape/__init__.py @@ -1,6 +1,8 @@ import sys as _sys +from functools import partial as _partial from pathlib import Path as _Path +from .api.contracts import _Contract from .managers.accounts import AccountManager as _AccountManager from .managers.compilers import CompilerManager as _CompilerManager from .managers.config import ConfigManager as _ConfigManager @@ -37,20 +39,17 @@ networks = _NetworkManager(config, plugin_manager) # type: ignore accounts = _AccountManager(config, plugin_manager, networks) # type: ignore - -def Project(path): - if isinstance(path, str): - path = _Path(path) - return _ProjectManager(path=path, config=config, compilers=compilers) - - +Project = _partial(_ProjectManager, config=config, compilers=compilers) project = Project(config.PROJECT_FOLDER) +Contract = _partial(_Contract, networks=networks) + __all__ = [ "accounts", "compilers", "config", + "Contract", "networks", "project", "Project", # So you can load other projects diff --git a/src/ape/api/__init__.py b/src/ape/api/__init__.py index 80d2b13beb..d08df6ef2d 100644 --- a/src/ape/api/__init__.py +++ b/src/ape/api/__init__.py @@ -1,15 +1,24 @@ from .accounts import AccountAPI, AccountContainerAPI +from .address import Address, AddressAPI +from .contracts import ContractLog from .explorers import ExplorerAPI from .networks import EcosystemAPI, NetworkAPI, ProviderContextManager, create_network_type -from .providers import ProviderAPI +from .providers import ProviderAPI, ReceiptAPI, TransactionAPI, TransactionStatusEnum __all__ = [ "AccountAPI", "AccountContainerAPI", + "Address", + "AddressAPI", + "ContractInstance", + "ContractLog", "EcosystemAPI", "ExplorerAPI", "ProviderAPI", "ProviderContextManager", "NetworkAPI", + "ReceiptAPI", + "TransactionAPI", + "TransactionStatusEnum", "create_network_type", ] diff --git a/src/ape/api/accounts.py b/src/ape/api/accounts.py index ab6afc9788..35215b8c11 100644 --- a/src/ape/api/accounts.py +++ b/src/ape/api/accounts.py @@ -1,81 +1,103 @@ from pathlib import Path -from typing import TYPE_CHECKING, Iterator, Optional, Type +from typing import Iterator, List, Optional, Type, Union from eth_account.datastructures import SignedMessage # type: ignore -from eth_account.datastructures import SignedTransaction from eth_account.messages import SignableMessage # type: ignore +from ape.types import ContractType + +from .address import AddressAPI from .base import abstractdataclass, abstractmethod +from .contracts import ContractContainer, ContractInstance +from .providers import ReceiptAPI, TransactionAPI -if TYPE_CHECKING: - from ape.managers.networks import NetworkManager +# NOTE: AddressAPI is a dataclass already +class AccountAPI(AddressAPI): + container: "AccountContainerAPI" -@abstractdataclass -class AddressAPI: - network_manager: Optional["NetworkManager"] = None + def __dir__(self) -> List[str]: + # This displays methods to IPython on `a.[TAB]` tab completion + return list(super(AddressAPI, self).__dir__()) + [ + "alias", + "sign_message", + "sign_transaction", + "call", + "transfer", + "deploy", + ] @property - def _provider(self): - if not self.network_manager: - raise Exception("Not wired correctly") - - if not self.network_manager.active_provider: - raise Exception("Not connected to any network!") - - return self.network_manager.active_provider + def alias(self) -> Optional[str]: + """ + Override with whatever alias might want to use, if applicable + """ + return None - @property @abstractmethod - def address(self) -> str: + def sign_message(self, msg: SignableMessage) -> Optional[SignedMessage]: ... - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {self.address}>" + def sign_transaction(self, txn: TransactionAPI) -> Optional[TransactionAPI]: + # NOTE: Some accounts may not offer signing things + return txn - def __str__(self) -> str: - return self.address + def call(self, txn: TransactionAPI) -> ReceiptAPI: + txn.nonce = self.nonce + txn.gas_limit = self.provider.estimate_gas_cost(txn) + txn.gas_price = self.provider.gas_price - @property - def nonce(self) -> int: - return self._provider.get_nonce(self.address) + if txn.gas_limit * txn.gas_price + txn.value > self.balance: + raise Exception("Transfer value meets or exceeds account balance") - @property - def balance(self) -> int: - return self._provider.get_balance(self.address) + signed_txn = self.sign_transaction(txn) - @property - def code(self) -> bytes: - # TODO: Explore caching this (based on `self.provider.network` and examining code) - return self._provider.get_code(self.address) + if not signed_txn: + raise Exception("User didn't sign!") - @property - def codesize(self) -> int: - return len(self.code) + return self.provider.send_transaction(signed_txn) - @property - def is_contract(self) -> bool: - return len(self.code) > 0 + def transfer( + self, + account: Union[str, "AddressAPI"], + value: int = None, + data: bytes = None, + ) -> ReceiptAPI: + txn = self._transaction_class( # type: ignore + sender=self.address, + receiver=account.address if isinstance(account, AddressAPI) else account, + ) + if data: + txn.data = data -# NOTE: AddressAPI is a dataclass already -class AccountAPI(AddressAPI): - container: "AccountContainerAPI" + if value: + txn.value = value - @property - def alias(self) -> Optional[str]: - """ - Override with whatever alias might want to use, if applicable - """ - return None + else: + # NOTE: If `value` is `None`, send everything + txn.value = self.balance - txn.gas_limit * txn.gas_price - @abstractmethod - def sign_message(self, msg: SignableMessage) -> Optional[SignedMessage]: - ... + return self.call(txn) - @abstractmethod - def sign_transaction(self, txn: dict) -> Optional[SignedTransaction]: - ... + def deploy(self, contract_type: ContractType, *args, **kwargs) -> ContractInstance: + c = ContractContainer( # type: ignore + _provider=self.provider, + _contract_type=contract_type, + ) + + txn = c(*args, **kwargs) + txn.sender = self.address + receipt = self.call(txn) + + if not receipt.contract_address: + raise Exception(f"{receipt.txn_hash} did not create a contract") + + return ContractInstance( # type: ignore + _provider=self.provider, + _address=receipt.contract_address, + _contract_type=contract_type, + ) @abstractdataclass diff --git a/src/ape/api/address.py b/src/ape/api/address.py new file mode 100644 index 0000000000..b01e5d9281 --- /dev/null +++ b/src/ape/api/address.py @@ -0,0 +1,76 @@ +from typing import List, Optional, Type + +from .base import abstractdataclass, abstractmethod +from .providers import ProviderAPI, ReceiptAPI, TransactionAPI + + +@abstractdataclass +class AddressAPI: + _provider: Optional[ProviderAPI] = None + + @property + def provider(self) -> ProviderAPI: + if not self._provider: + raise Exception("Wired incorrectly") + + return self._provider + + @property + def _receipt_class(self) -> Type[ReceiptAPI]: + return self.provider.network.ecosystem.receipt_class + + @property + def _transaction_class(self) -> Type[TransactionAPI]: + return self.provider.network.ecosystem.transaction_class + + @property + @abstractmethod + def address(self) -> str: + ... + + def __dir__(self) -> List[str]: + # This displays methods to IPython on `a.[TAB]` tab completion + return [ + "address", + "balance", + "code", + "codesize", + "nonce", + "is_contract", + "provider", + ] + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.address}>" + + def __str__(self) -> str: + return self.address + + @property + def nonce(self) -> int: + return self.provider.get_nonce(self.address) + + @property + def balance(self) -> int: + return self.provider.get_balance(self.address) + + @property + def code(self) -> bytes: + # TODO: Explore caching this (based on `self.provider.network` and examining code) + return self.provider.get_code(self.address) + + @property + def codesize(self) -> int: + return len(self.code) + + @property + def is_contract(self) -> bool: + return len(self.code) > 0 + + +class Address(AddressAPI): + _address: str + + @property + def address(self) -> str: + return self._address diff --git a/src/ape/api/base.py b/src/ape/api/base.py index a39593165e..de507a740a 100644 --- a/src/ape/api/base.py +++ b/src/ape/api/base.py @@ -9,7 +9,8 @@ class AbstractDataClassMeta(DataClassMeta, ABCMeta): pass -abstractdataclass = partial(dataclass, meta=AbstractDataClassMeta) +abstractdataclass = partial(dataclass, kwargs=True, meta=AbstractDataClassMeta) + __all__ = [ "abstractdataclass", diff --git a/src/ape/api/contracts.py b/src/ape/api/contracts.py new file mode 100644 index 0000000000..5d707c9b37 --- /dev/null +++ b/src/ape/api/contracts.py @@ -0,0 +1,315 @@ +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from eth_utils import to_bytes + +from ape.types import ABI, ContractType +from ape.utils import notify + +from .address import Address, AddressAPI +from .base import dataclass +from .providers import ProviderAPI, ReceiptAPI, TransactionAPI + +if TYPE_CHECKING: + from ape.managers.networks import NetworkManager + + +@dataclass +class ContractConstructor: + deployment_bytecode: bytes + abi: Optional[ABI] + provider: ProviderAPI + + def __post_init__(self): + if len(self.deployment_bytecode) == 0: + raise Exception("No bytecode to deploy") + + def __repr__(self) -> str: + return self.abi.signature if self.abi else "constructor()" + + def encode(self, *args, **kwargs) -> TransactionAPI: + return self.provider.network.ecosystem.encode_deployment( + self.deployment_bytecode, self.abi, *args, **kwargs + ) + + def __call__(self, *args, **kwargs) -> ReceiptAPI: + if "sender" in kwargs: + sender = kwargs["sender"] + kwargs["sender"] = sender.address + + txn = self.encode(*args, **kwargs) + return sender.call(txn) + + else: + txn = self.encode(*args, **kwargs) + return self.provider.send_transaction(txn) + + +@dataclass +class ContractCall: + abi: ABI + address: str + provider: ProviderAPI + + def __repr__(self) -> str: + return self.abi.signature + + def encode(self, *args, **kwargs) -> TransactionAPI: + return self.provider.network.ecosystem.encode_transaction( + self.address, self.abi, *args, **kwargs + ) + + def __call__(self, *args, **kwargs) -> Any: + txn = self.encode(*args, **kwargs) + + if "sender" in kwargs and not isinstance(kwargs["sender"], str): + txn.sender = kwargs["sender"].address + + raw_output = self.provider.send_call(txn) + tuple_output = self.provider.network.ecosystem.decode_calldata( # type: ignore + self.abi, + raw_output, + ) + + # NOTE: Returns a tuple, so make sure to handle all the cases + if len(tuple_output) < 2: + return tuple_output[0] if len(tuple_output) == 1 else None + + else: + # TODO: Handle struct output + return tuple_output + + +@dataclass +class ContractCallHandler: + provider: ProviderAPI + address: str + abis: List[ABI] + + def __repr__(self) -> str: + abis = sorted(self.abis, key=lambda abi: len(abi.inputs)) + return abis[-1].signature + + def __call__(self, *args, **kwargs) -> Any: + selected_abi = None + for abi in self.abis: + if len(args) == len(abi.inputs): + selected_abi = abi + + if not selected_abi: + raise Exception("Number of args does not match") + + return ContractCall( # type: ignore + abi=selected_abi, + address=self.address, + provider=self.provider, + )(*args, **kwargs) + + +@dataclass +class ContractTransaction: + abi: ABI + address: str + provider: ProviderAPI + + def __repr__(self) -> str: + return self.abi.signature + + def encode(self, *args, **kwargs) -> TransactionAPI: + return self.provider.network.ecosystem.encode_transaction( + self.address, self.abi, *args, **kwargs + ) + + def __call__(self, *args, **kwargs) -> ReceiptAPI: + + if "sender" in kwargs: + sender = kwargs["sender"] + kwargs["sender"] = sender.address + + txn = self.encode(*args, **kwargs) + return sender.call(txn) + + else: + raise Exception("Must specify a `sender`") + + +@dataclass +class ContractTransactionHandler: + provider: ProviderAPI + address: str + abis: List[ABI] + + def __repr__(self) -> str: + abis = sorted(self.abis, key=lambda abi: len(abi.inputs)) + return abis[-1].signature + + def __call__(self, *args, **kwargs) -> ReceiptAPI: + selected_abi = None + for abi in self.abis: + if len(args) == len(abi.inputs): + selected_abi = abi + + if not selected_abi: + raise Exception("Number of args does not match") + + return ContractTransaction( # type: ignore + abi=selected_abi, + address=self.address, + provider=self.provider, + )(*args, **kwargs) + + +@dataclass +class ContractLog: + name: str + data: Dict[str, Any] + + +@dataclass +class ContractEvent: + provider: ProviderAPI + address: str + abis: List[ABI] + cached_logs: List[ContractLog] = [] + + +class ContractInstance(AddressAPI): + _address: str + _contract_type: ContractType + + def __repr__(self) -> str: + return f"<{self._contract_type.contractName} {self.address}>" + + @property + def address(self) -> str: + return self._address + + def __dir__(self) -> List[str]: + # This displays methods to IPython on `c.[TAB]` tab completion + return list(super(AddressAPI, self).__dir__()) + [ + abi.name for abi in self._contract_type.abi + ] + + def __getattr__(self, attr_name: str) -> Any: + handlers = { + "events": ContractEvent, + "calls": ContractCallHandler, + "transactions": ContractTransactionHandler, + } + + def get_handler(abi_type: str) -> Any: + selected_abis = [ + abi for abi in getattr(self._contract_type, abi_type) if abi.name == attr_name + ] + + if not selected_abis: + return # No ABIs found for this type + + kwargs = { + "provider": self.provider, + "address": self.address, + "abis": selected_abis, + } + + try: + return handlers[abi_type](**kwargs) # type: ignore + + except Exception as e: + # NOTE: Just a hack, because `__getattr__` *must* raise `AttributeError` + raise AttributeError from e + + # Reverse search for the proper handler for this ABI name, if one exists + for abi_type in handlers: + handler = get_handler(abi_type) + if handler: + return handler + # else: No ABI found with `attr_name` + + # No ABIs w/ name `attr_name` found at all + raise AttributeError(f"{self.__class__.__name__} has no attribute '{attr_name}'") + + +@dataclass +class ContractContainer: + _provider: ProviderAPI + _contract_type: ContractType + + def __repr__(self) -> str: + return f"<{self._contract_type.contractName}>" + + def at(self, address: str) -> ContractInstance: + return ContractInstance( # type: ignore + _address=address, + _provider=self._provider, + _contract_type=self._contract_type, + ) + + @property + def _deployment_bytecode(self) -> bytes: + if ( + self._contract_type.deploymentBytecode + and self._contract_type.deploymentBytecode.bytecode + ): + return to_bytes(hexstr=self._contract_type.deploymentBytecode.bytecode) + + else: + return b"" + + @property + def _runtime_bytecode(self) -> bytes: + if self._contract_type.runtimeBytecode and self._contract_type.runtimeBytecode.bytecode: + return to_bytes(hexstr=self._contract_type.runtimeBytecode.bytecode) + + else: + return b"" + + def __call__(self, *args, **kwargs) -> TransactionAPI: + constructor = ContractConstructor( # type: ignore + abi=self._contract_type.constructor, + provider=self._provider, + deployment_bytecode=self._deployment_bytecode, + ) + return constructor.encode(*args, **kwargs) + + +def _Contract( + address: str, + networks: "NetworkManager", + contract_type: Optional[ContractType] = None, +) -> AddressAPI: + """ + Function used to triage whether we have a contract type available for + the given address/network combo, or explicitly provided. If none are found, + returns a simple `Address` instance instead of throwing (provides a warning) + """ + + # Check contract cache (e.g. previously deployed/downloaded contracts) + # TODO: Add `contract_cache` dict-like object to `NetworkAPI` + # network = provider.network + # if not contract_type and address in network.contract_cache: + # contract_type = network.contract_cache[address] + + # Check explorer API/cache (e.g. publicly published contracts) + # TODO: Add `get_contract_type` to `ExplorerAPI` + # TODO: Store in `NetworkAPI.contract_cache` to reduce API calls + # explorer = provider.network.explorer + # if not contract_type and explorer: + # contract_type = explorer.get_contract_type(address) + + # We have a contract type either: + # 1) explicity provided, + # 2) from network cache, or + # 3) from explorer + if contract_type: + return ContractInstance( # type: ignore + _address=address, + _provider=networks.active_provider, + _contract_type=contract_type, + ) + + else: + # We don't have a contract type from any source, provide raw address instead + notify("WARNING", f"No contract type found for {address}") + return Address( # type: ignore + _address=address, + _provider=networks.active_provider, + ) diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index 11590ac9ea..315ed8dd42 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -4,18 +4,20 @@ from pluggy import PluginManager # type: ignore +from ape.types import ABI from ape.utils import cached_property -from .base import abstractdataclass, abstractmethod, dataclass +from .base import abstractdataclass, abstractmethod if TYPE_CHECKING: from ape.managers.networks import NetworkManager + from .contracts import ContractLog from .explorers import ExplorerAPI - from .providers import ProviderAPI + from .providers import ProviderAPI, ReceiptAPI, TransactionAPI -@dataclass +@abstractdataclass class EcosystemAPI: """ An Ecosystem is a set of related Networks @@ -27,6 +29,9 @@ class EcosystemAPI: data_folder: Path request_header: str + transaction_class: Type["TransactionAPI"] + receipt_class: Type["ReceiptAPI"] + _default_network: str = "development" @cached_property @@ -94,6 +99,20 @@ def set_default_network(self, network_name: str): else: raise Exception("Not a valid network for ecosystem `self.name`") + @abstractmethod + def encode_deployment( + self, deployment_bytecode: bytes, abi: Optional[ABI], *args, **kwargs + ) -> "TransactionAPI": + ... + + @abstractmethod + def encode_transaction(self, address: str, abi: ABI, *args, **kwargs) -> "TransactionAPI": + ... + + @abstractmethod + def decode_event(self, abi: ABI, receipt: "ReceiptAPI") -> "ContractLog": + ... + class ProviderContextManager: # NOTE: Class variable, so it will manage stack across instances of this object diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index 7b368263c1..599cac6ccf 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -1,9 +1,90 @@ +from enum import IntEnum from pathlib import Path +from typing import Iterator, List, Optional + +from dataclassy import as_dict + +from ape.utils import notify from . import networks from .base import abstractdataclass, abstractmethod +@abstractdataclass +class TransactionAPI: + chain_id: int = 0 + sender: str = "" + receiver: str = "" + nonce: int = 0 + value: int = 0 + gas_limit: int = 0 + gas_price: int = 0 + data: bytes = b"" + + signature: bytes = b"" + + def __post_init__(self): + if not self.is_valid: + raise Exception("Transaction is not valid!") + + @property + @abstractmethod + def is_valid(self): + ... + + @abstractmethod + def encode(self) -> bytes: + """ + Take this object and produce a hash to sign to submit a transaction + """ + + def as_dict(self) -> dict: + return as_dict(self) + + def __repr__(self) -> str: + data = as_dict(self) # NOTE: `as_dict` could be overriden + params = ", ".join(f"{k}={v}" for k, v in data.items()) + return f"<{self.__class__.__name__} {params}>" + + def __str__(self) -> str: + data = as_dict(self) # NOTE: `as_dict` could be overriden + if len(data["data"]) > 9: + data["data"] = ( + "0x" + bytes(data["data"][:3]).hex() + "..." + bytes(data["data"][-3:]).hex() + ) + else: + data["data"] = "0x" + bytes(data["data"]).hex() + params = "\n ".join(f"{k}: {v}" for k, v in data.items()) + return f"{self.__class__.__name__}:\n {params}" + + +class TransactionStatusEnum(IntEnum): + failing = 0 + no_error = 1 + + +@abstractdataclass +class ReceiptAPI: + txn_hash: str + status: TransactionStatusEnum + block_number: int + gas_used: int + gas_price: int + logs: List[dict] = [] + contract_address: Optional[str] = None + + def __post_init__(self): + notify("INFO", f"Submitted {self.txn_hash.hex()}") + + def __str__(self) -> str: + return f"<{self.__class__.__name__} {self.txn_hash}>" + + @classmethod + @abstractmethod + def decode(cls, data: dict) -> "ReceiptAPI": + ... + + @abstractdataclass class ProviderAPI: """ @@ -41,14 +122,26 @@ def get_nonce(self, address: str) -> int: ... @abstractmethod - def transfer_cost(self, address: str) -> int: + def estimate_gas_cost(self, txn: TransactionAPI) -> int: ... @property @abstractmethod - def gas_price(self): + def gas_price(self) -> int: + ... + + @abstractmethod + def send_call(self, txn: TransactionAPI) -> bytes: # Return value of function + ... + + @abstractmethod + def get_transaction(self, txn_hash: str) -> ReceiptAPI: + ... + + @abstractmethod + def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: ... @abstractmethod - def send_transaction(self, data: bytes) -> bytes: + def get_events(self, **filter_params) -> Iterator[dict]: ... diff --git a/src/ape/managers/accounts.py b/src/ape/managers/accounts.py index 09c375308f..d47fcfa014 100644 --- a/src/ape/managers/accounts.py +++ b/src/ape/managers/accounts.py @@ -45,8 +45,8 @@ def __len__(self) -> int: def __iter__(self) -> Iterator[AccountAPI]: for container in self.containers.values(): for account in container: - # NOTE: Inject network manager - account.network_manager = self.network_manager + # NOTE: Inject provider + account._provider = self.network_manager.active_provider yield account def load(self, alias: str) -> AccountAPI: @@ -55,8 +55,8 @@ def load(self, alias: str) -> AccountAPI: for account in self: if account.alias and account.alias == alias: - # NOTE: Inject network manager - account.network_manager = self.network_manager + # NOTE: Inject provider + account._provider = self.network_manager.active_provider return account raise IndexError(f"No account with alias `{alias}`.") @@ -69,8 +69,8 @@ def __getitem__(self, account_id) -> AccountAPI: def __getitem_int(self, account_id: int) -> AccountAPI: for idx, account in enumerate(self.__iter__()): if account_id == idx: - # NOTE: Inject network manager - account.network_manager = self.network_manager + # NOTE: Inject provider + account._provider = self.network_manager.active_provider return account raise IndexError(f"No account at index `{account_id}`.") @@ -80,8 +80,8 @@ def __getitem_str(self, account_id: str) -> AccountAPI: for container in self.containers.values(): if account_id in container: account = container[account_id] - # NOTE: Inject network manager - account.network_manager = self.network_manager + # NOTE: Inject provider + account._provider = self.network_manager.active_provider return account raise IndexError(f"No account with address `{account_id}`.") diff --git a/src/ape/types/abstract.py b/src/ape/types/abstract.py index d82413a1ce..eea1dcf09d 100644 --- a/src/ape/types/abstract.py +++ b/src/ape/types/abstract.py @@ -50,7 +50,7 @@ def to_dict(v: Any) -> Optional[Union[list, dict, str, int, bool]]: return v else: - raise # Unhandled type + raise Exception(f"Unhandled type '{type(v)}'") @dc.dataclass(slots=True, kwargs=True, repr=True) @@ -75,9 +75,11 @@ def from_dict(cls, params: Dict): class FileMixin(SerializableType): @classmethod def from_file(cls, path: Path): - return cls.from_dict(json.load(path.open())) + with path.open("r") as f: + return cls.from_dict(json.load(f)) def to_file(self, path: Path): # NOTE: EIP-2678 specifies document *must* be tightly packed # NOTE: EIP-2678 specifies document *must* have sorted keys - json.dump(self.to_dict(), path.open("w"), indent=4, sort_keys=True) + with path.open("w") as f: + json.dump(self.to_dict(), f, indent=4, sort_keys=True) diff --git a/src/ape/types/contract.py b/src/ape/types/contract.py index 61bed38408..766eee6c51 100644 --- a/src/ape/types/contract.py +++ b/src/ape/types/contract.py @@ -72,6 +72,14 @@ class ABIType(SerializableType): type: Union[str, "ABIType"] internalType: Optional[str] = None + @property + def canonical_type(self) -> str: + if isinstance(self.type, str): + return self.type + + else: + return self.type.canonical_type + class ABI(SerializableType): name: str = "" @@ -87,10 +95,52 @@ class ABI(SerializableType): # Might make most sense to add to `ContractType` as a serde extension type: str + @property + def signature(self) -> str: + """ + String representing the function/event signature, which includes the arg names and types, + and output names (if any) and type(s) + """ + name = self.name if (self.type == "function" or self.type == "event") else self.type + + def encode_arg(arg: ABIType) -> str: + encoded_arg = arg.canonical_type + # For events (handles both None and False conditions) + if arg.indexed: + encoded_arg += " indexed" + if arg.name: + encoded_arg += f" {arg.name}" + return encoded_arg + + input_args = ", ".join(map(encode_arg, self.inputs)) + output_args = "" + + if self.outputs: + output_args = " -> " + if len(self.outputs) > 1: + output_args += "(" + ", ".join(map(encode_arg, self.outputs)) + ")" + else: + output_args += encode_arg(self.outputs[0]) + + return f"{name}({input_args}){output_args}" + + @property + def selector(self) -> str: + """ + String representing the function selector, used to compute `method_id` and `event_id` + """ + name = self.name if (self.type == "function" or self.type == "event") else self.type + input_names = ", ".join(i.canonical_type for i in self.inputs) + return f"{name}({input_names})" + @property def is_event(self) -> bool: return self.anonymous is not None + @property + def is_payable(self) -> bool: + return self.stateMutability == "payable" + @property def is_stateful(self) -> bool: return self.stateMutability not in ("view", "pure") @@ -155,11 +205,11 @@ def events(self) -> List[ABI]: @property def calls(self) -> List[ABI]: - return [abi for abi in self.abi if abi.type == "function" and abi.is_stateful] + return [abi for abi in self.abi if abi.type == "function" and not abi.is_stateful] @property def transactions(self) -> List[ABI]: - return [abi for abi in self.abi if abi.type == "function" and not abi.is_stateful] + return [abi for abi in self.abi if abi.type == "function" and abi.is_stateful] @classmethod def from_dict(cls, params: Dict): diff --git a/src/ape_accounts/accounts.py b/src/ape_accounts/accounts.py index d7702e095b..424b8137ce 100644 --- a/src/ape_accounts/accounts.py +++ b/src/ape_accounts/accounts.py @@ -5,10 +5,9 @@ import click from eth_account import Account as EthAccount # type: ignore from eth_account.datastructures import SignedMessage # type: ignore -from eth_account.datastructures import SignedTransaction from eth_account.messages import SignableMessage # type: ignore -from ape.api.accounts import AccountAPI, AccountContainerAPI +from ape.api import AccountAPI, AccountContainerAPI, TransactionAPI from ape.convert import to_address @@ -65,7 +64,11 @@ def __key(self) -> EthAccount: default="", # Just in case there's no passphrase ) - key = EthAccount.decrypt(self.keyfile, passphrase) + try: + key = EthAccount.decrypt(self.keyfile, passphrase) + + except ValueError as e: + raise Exception("Invalid password") from e if click.confirm(f"Leave '{self.alias}' unlocked?"): self.locked = False @@ -79,7 +82,11 @@ def unlock(self): hide_input=True, ) - self.__cached_key = EthAccount.decrypt(self.keyfile, passphrase) + try: + self.__cached_key = EthAccount.decrypt(self.keyfile, passphrase) + + except ValueError as e: + raise Exception("Invalid password") from e def lock(self): self.locked = True @@ -108,13 +115,21 @@ def delete(self): self._keyfile.unlink() def sign_message(self, msg: SignableMessage) -> Optional[SignedMessage]: - if self.locked and not click.confirm(f"Sign: {msg}"): + if self.locked and not click.confirm(f"{msg}\n\nSign: "): return None return EthAccount.sign_message(msg, self.__key) - def sign_transaction(self, txn: dict) -> Optional[SignedTransaction]: - if self.locked and not click.confirm(f"Sign: {txn}"): + def sign_transaction(self, txn: TransactionAPI) -> Optional[TransactionAPI]: + if self.locked and not click.confirm(f"{txn}\n\nSign: "): return None - return EthAccount.sign_transaction(txn, self.__key) + signed_txn = EthAccount.sign_transaction(txn.as_dict(), self.__key) + + txn.signature = ( + signed_txn.v.to_bytes(1, "big") + + signed_txn.r.to_bytes(32, "big") + + signed_txn.s.to_bytes(32, "big") + ) + + return txn diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index e068834f4f..86f07a09c2 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -1,4 +1,17 @@ -from ape.api import EcosystemAPI +from typing import Any, Optional + +from eth_abi import decode_abi as abi_decode +from eth_abi import encode_abi as abi_encode +from eth_abi.exceptions import InsufficientDataBytes +from eth_account._utils.transactions import ( # type: ignore + encode_transaction, + serializable_unsigned_transaction_from_dict, +) +from eth_utils import keccak, to_bytes, to_int # type: ignore +from hexbytes import HexBytes + +from ape.api import ContractLog, EcosystemAPI, ReceiptAPI, TransactionAPI, TransactionStatusEnum +from ape.types import ABI NETWORKS = { # chain_id, network_id @@ -10,8 +23,97 @@ } +class Transaction(TransactionAPI): + def is_valid(self) -> bool: + return False + + def as_dict(self) -> dict: + data = super().as_dict() + + # Clean up data to what we expect + data.pop("chain_id") + data.pop("sender") + data["to"] = data.pop("receiver") + data["gas"] = data.pop("gas_limit") + data["gasPrice"] = data.pop("gas_price") + + # NOTE: Don't publish signature + data.pop("signature") + + return data + + def encode(self) -> bytes: + data = self.as_dict() + unsigned_txn = serializable_unsigned_transaction_from_dict(data) + return encode_transaction( + unsigned_txn, + ( + to_int(self.signature[:1]), + to_int(self.signature[1:33]), + to_int(self.signature[33:65]), + ), + ) + + +class Receipt(ReceiptAPI): + @classmethod + def decode(cls, data: dict) -> ReceiptAPI: + return cls( # type: ignore + txn_hash=data["hash"], + status=TransactionStatusEnum(data["status"]), + block_number=data["blockNumber"], + gas_used=data["gasUsed"], + gas_price=data["gasPrice"], + logs=data["logs"], + contract_address=data["contractAddress"], + ) + + class Ethereum(EcosystemAPI): - pass + transaction_class = Transaction + receipt_class = Receipt + + def encode_calldata(self, abi: ABI, *args) -> bytes: + if abi.inputs: + input_types = [i.canonical_type for i in abi.inputs] + return abi_encode(input_types, args) + + else: + return HexBytes(b"") + + def decode_calldata(self, abi: ABI, raw_data: bytes) -> Any: + output_types = [o.canonical_type for o in abi.outputs] + try: + return abi_decode(output_types, raw_data) + + except InsufficientDataBytes as e: + raise Exception("Output corrupted") from e + + def encode_deployment( + self, deployment_bytecode: bytes, abi: Optional[ABI], *args, **kwargs + ) -> Transaction: + txn = Transaction(**kwargs) # type: ignore + txn.data = deployment_bytecode + + # Encode args, if there are any + if abi: + txn.data += self.encode_calldata(abi, *args) + + return txn + + def encode_transaction(self, address: str, abi: ABI, *args, **kwargs) -> Transaction: + txn = Transaction(receiver=address, **kwargs) # type: ignore + + # Add method ID + txn.data = keccak(to_bytes(text=abi.selector))[:4] + txn.data += self.encode_calldata(abi, *args) + return txn -# TODO: Define VM-specific stuff here + def decode_event(self, abi: ABI, receipt: "ReceiptAPI") -> "ContractLog": + filter_id = keccak(to_bytes(text=abi.selector)) + event_data = next(log for log in receipt.logs if log["filter_id"] == filter_id) + return ContractLog( # type: ignore + name=abi.name, + inputs={i.name: event_data[i.name] for i in abi.inputs}, + ) diff --git a/src/ape_infura/providers.py b/src/ape_infura/providers.py index a1cb460b2b..5bd6d51d24 100644 --- a/src/ape_infura/providers.py +++ b/src/ape_infura/providers.py @@ -1,9 +1,10 @@ import os +from typing import Iterator from web3 import HTTPProvider, Web3 # type: ignore from web3.gas_strategies.rpc import rpc_gas_price_strategy -from ape.api import ProviderAPI +from ape.api import ProviderAPI, ReceiptAPI, TransactionAPI class Infura(ProviderAPI): @@ -20,11 +21,11 @@ def connect(self): def disconnect(self): pass - def transfer_cost(self, address: str) -> int: - if self.get_code(address) == b"": - return 21000 - else: - raise Exception("Transfer cost error") + def update_settings(self, new_settings: dict): + pass + + def estimate_gas_cost(self, txn: TransactionAPI) -> int: + return self._web3.eth.estimate_gas(txn.as_dict()) # type: ignore @property def gas_price(self): @@ -39,5 +40,19 @@ def get_balance(self, address: str) -> int: def get_code(self, address: str) -> bytes: return self._web3.eth.getCode(address) # type: ignore - def send_transaction(self, data: bytes) -> bytes: - return self._web3.eth.sendRawTransaction(data) # type: ignore + def send_call(self, txn: TransactionAPI) -> bytes: + data = txn.encode() + return self._web3.eth.call(data) + + def get_transaction(self, txn_hash: str) -> ReceiptAPI: + # TODO: Work on API that let's you work with ReceiptAPI and re-send transactions + receipt = self._web3.eth.wait_for_transaction_receipt(txn_hash) # type: ignore + txn = self._web3.eth.get_transaction(txn_hash) # type: ignore + return self.network.ecosystem.receipt_class.decode({**txn, **receipt}) + + def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: + txn_hash = self._web3.eth.send_raw_transaction(txn.encode()) + return self.get_transaction(txn_hash.hex()) + + def get_events(self, **filter_params) -> Iterator[dict]: + return iter(self._web3.eth.get_logs(filter_params)) # type: ignore diff --git a/src/ape_test/providers.py b/src/ape_test/providers.py index fdb945cf8f..24d7c6903b 100644 --- a/src/ape_test/providers.py +++ b/src/ape_test/providers.py @@ -1,6 +1,8 @@ +from typing import Iterator + from web3 import EthereumTesterProvider, Web3 # type: ignore -from ape.api import ProviderAPI +from ape.api import ProviderAPI, ReceiptAPI, TransactionAPI class LocalNetwork(ProviderAPI): @@ -18,14 +20,12 @@ def update_settings(self, new_settings: dict): def __post_init__(self): self._web3 = Web3(EthereumTesterProvider()) - def transfer_cost(self, address: str) -> int: - if self.get_code(address) == b"": - return 21000 - else: - raise Exception("Transfer cost error") + def estimate_gas_cost(self, txn: TransactionAPI) -> int: + return self._web3.eth.estimate_gas(txn.as_dict()) # type: ignore @property def gas_price(self): + # NOTE: Test chain doesn't care about gas prices return 0 def get_nonce(self, address: str) -> int: @@ -37,5 +37,21 @@ def get_balance(self, address: str) -> int: def get_code(self, address: str) -> bytes: return self._web3.eth.get_code(address) # type: ignore - def send_transaction(self, data: bytes) -> bytes: - return self._web3.eth.send_raw_transaction(data) # type: ignore + def send_call(self, txn: TransactionAPI) -> bytes: + data = txn.as_dict() + if data["gas"] == 0: + data["gas"] = int(1e12) + return self._web3.eth.call(data) + + def get_transaction(self, txn_hash: str) -> ReceiptAPI: + # TODO: Work on API that let's you work with ReceiptAPI and re-send transactions + receipt = self._web3.eth.wait_for_transaction_receipt(txn_hash) # type: ignore + txn = self._web3.eth.get_transaction(txn_hash) # type: ignore + return self.network.ecosystem.receipt_class.decode({**txn, **receipt}) + + def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: + txn_hash = self._web3.eth.send_raw_transaction(txn.encode()) + return self.get_transaction(txn_hash.hex()) + + def get_events(self, **filter_params) -> Iterator[dict]: + return iter(self._web3.eth.get_logs(filter_params)) # type: ignore