diff --git a/setup.py b/setup.py index 9dd3d702cd..8d4cdc740d 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ "backports.cached_property ; python_version<'3.8'", "click>=8.0.0", "dataclassy==0.10.4", # NOTE: Pinned due to issue with `Type[]` - "eth-account>=0.5.5,<0.6.0", + "eth-account>=0.5.6,<0.6.0", "pluggy>=0.13.1,<1.0", "PyGithub>=1.54,<2.0", "pyyaml>=0.2.5", @@ -85,7 +85,7 @@ "singledispatchmethod ; python_version<'3.8'", "IPython>=7.25", "pytest>=6.0,<7.0", - "web3[tester]>=5.18.0,<6.0.0", + "web3[tester]>=5.24.0,<6.0.0", ], entry_points={ "console_scripts": ["ape=ape._cli:cli"], diff --git a/src/ape/api/__init__.py b/src/ape/api/__init__.py index 910102eaaa..9a9ccea957 100644 --- a/src/ape/api/__init__.py +++ b/src/ape/api/__init__.py @@ -10,6 +10,7 @@ TestProviderAPI, TransactionAPI, TransactionStatusEnum, + TransactionType, Web3Provider, ) @@ -33,5 +34,6 @@ "TestProviderAPI", "TransactionAPI", "TransactionStatusEnum", + "TransactionType", "Web3Provider", ] diff --git a/src/ape/api/accounts.py b/src/ape/api/accounts.py index 94f0d32566..ec4cde3da6 100644 --- a/src/ape/api/accounts.py +++ b/src/ape/api/accounts.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, Iterator, List, Optional, Type, Union +from ape.exceptions import AccountsError, AliasAlreadyInUseError, SignatureError from ape.types import ( AddressType, ContractType, @@ -10,11 +11,10 @@ ) from ape.utils import cached_property -from ..exceptions import AccountsError, AliasAlreadyInUseError, SignatureError from .address import AddressAPI from .base import abstractdataclass, abstractmethod from .contracts import ContractContainer, ContractInstance -from .providers import ReceiptAPI, TransactionAPI +from .providers import ReceiptAPI, TransactionAPI, TransactionType if TYPE_CHECKING: from ape.managers.config import ConfigManager @@ -76,18 +76,23 @@ def call(self, txn: TransactionAPI, send_everything: bool = False) -> ReceiptAPI elif txn.nonce < self.nonce: raise AccountsError("Invalid nonce, will not publish.") - # TODO: Add `GasEstimationAPI` - if txn.gas_price is None: - txn.gas_price = self.provider.gas_price - # else: assume user specified a correct price, or will take too much time to confirm + txn_type = TransactionType(txn.type) + if txn_type == TransactionType.STATIC and txn.gas_price is None: # type: ignore + txn.gas_price = self.provider.gas_price # type: ignore + elif txn_type == TransactionType.DYNAMIC: + if txn.max_priority_fee is None: # type: ignore + txn.max_priority_fee = self.provider.priority_fee # type: ignore + + if txn.max_fee is None: + txn.max_fee = self.provider.base_fee + txn.max_priority_fee + # else: Assume user specified the correct amount or txn will fail and waste gas - # NOTE: Allow overriding gas limit if txn.gas_limit is None: txn.gas_limit = self.provider.estimate_gas_cost(txn) - # else: assume user specified the correct amount or txn will fail and waste gas + # else: Assume user specified the correct amount or txn will fail and waste gas if send_everything: - txn.value = self.balance - txn.gas_limit * txn.gas_price + txn.value = self.balance - txn.max_fee if txn.total_transfer_value > self.balance: raise AccountsError( diff --git a/src/ape/api/address.py b/src/ape/api/address.py index 822b621d55..310fa1ada1 100644 --- a/src/ape/api/address.py +++ b/src/ape/api/address.py @@ -1,10 +1,10 @@ -from typing import List, Optional, Type +from typing import List, Optional +from ape.exceptions import AddressError from ape.types import AddressType -from ..exceptions import AddressError from .base import abstractdataclass, abstractmethod -from .providers import ProviderAPI, ReceiptAPI, TransactionAPI +from .providers import ProviderAPI @abstractdataclass @@ -24,14 +24,6 @@ def provider(self) -> ProviderAPI: def provider(self, value: ProviderAPI): self._provider = value - @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) -> AddressType: diff --git a/src/ape/api/contracts.py b/src/ape/api/contracts.py index 5328f56e7d..2b7931b957 100644 --- a/src/ape/api/contracts.py +++ b/src/ape/api/contracts.py @@ -2,10 +2,10 @@ from eth_utils import to_bytes +from ape.exceptions import ArgumentsLengthError, ContractDeployError, TransactionError from ape.logging import logger from ape.types import ABI, AddressType, ContractType -from ..exceptions import ArgumentsLengthError, ContractDeployError, TransactionError from .address import Address, AddressAPI from .base import dataclass from .providers import ProviderAPI, ReceiptAPI, TransactionAPI diff --git a/src/ape/api/networks.py b/src/ape/api/networks.py index f9dae22302..8792966f6d 100644 --- a/src/ape/api/networks.py +++ b/src/ape/api/networks.py @@ -4,10 +4,10 @@ from pluggy import PluginManager # type: ignore +from ape.exceptions import NetworkError, NetworkNotFoundError from ape.types import ABI, AddressType from ape.utils import cached_property -from ..exceptions import NetworkError, NetworkNotFoundError from .base import abstractdataclass, abstractmethod, dataclass from .config import ConfigItem @@ -17,7 +17,7 @@ from .contracts import ContractLog from .explorers import ExplorerAPI - from .providers import ProviderAPI, ReceiptAPI, TransactionAPI + from .providers import ProviderAPI, ReceiptAPI, TransactionAPI, TransactionType @abstractdataclass @@ -33,7 +33,7 @@ class EcosystemAPI: data_folder: Path request_header: str - transaction_class: Type["TransactionAPI"] + transaction_types: Dict["TransactionType", Type["TransactionAPI"]] receipt_class: Type["ReceiptAPI"] _default_network: str = "development" diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index 12c894790f..3d706cc8ef 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -1,4 +1,4 @@ -from enum import IntEnum +from enum import Enum, IntEnum from pathlib import Path from typing import Iterator, List, Optional @@ -6,15 +6,20 @@ from hexbytes import HexBytes from web3 import Web3 +from ape.exceptions import ProviderError from ape.logging import logger from ape.types import TransactionSignature -from ..exceptions import ProviderError from . import networks from .base import abstractdataclass, abstractmethod from .config import ConfigItem +class TransactionType(Enum): + STATIC = "0x0" + DYNAMIC = "0x2" # EIP-1559 + + @abstractdataclass class TransactionAPI: chain_id: int = 0 @@ -23,8 +28,8 @@ class TransactionAPI: nonce: Optional[int] = None # NOTE: `Optional` only to denote using default behavior value: int = 0 gas_limit: Optional[int] = None # NOTE: `Optional` only to denote using default behavior - gas_price: Optional[int] = None # NOTE: `Optional` only to denote using default behavior data: bytes = b"" + type: TransactionType = TransactionType.STATIC signature: Optional[TransactionSignature] = None @@ -32,6 +37,21 @@ def __post_init__(self): if not self.is_valid: raise ProviderError("Transaction is not valid.") + @property + def max_fee(self) -> int: + """ + The total amount in fees willing to be spent on a transaction. + Override this property as needed, such as for EIP-1559 differences. + + See :class:`~ape_ethereum.ecosystem.StaticFeeTransaction` and + :class`~ape_ethereum.ecosystem.DynamicFeeTransaction` as examples. + """ + return 0 + + @max_fee.setter + def max_fee(self, value): + raise NotImplementedError("Max fee is not settable by default.") + @property def total_transfer_value(self) -> int: """ @@ -39,8 +59,7 @@ def total_transfer_value(self) -> int: Useful for determining if an account balance can afford to submit the transaction. """ - # TODO Support EIP-1559 - return (self.gas_limit or 0) * (self.gas_price or 0) + self.value + return self.value + self.max_fee @property @abstractmethod @@ -95,6 +114,12 @@ def __post_init__(self): def __str__(self) -> str: return f"<{self.__class__.__name__} {self.txn_hash}>" + def raise_for_status(self, txn: TransactionAPI): + """ + Handle provider-specific errors regarding a non-successful + :class:`~api.providers.TransactionStatusEnum`. + """ + def ran_out_of_gas(self, gas_limit: int) -> bool: """ Returns ``True`` when the transaction failed and used the @@ -159,6 +184,16 @@ def estimate_gas_cost(self, txn: TransactionAPI) -> int: def gas_price(self) -> int: ... + @property + @abstractmethod + def priority_fee(self) -> int: + raise NotImplementedError("priority_fee is not implemented by this provider") + + @property + @abstractmethod + def base_fee(self) -> int: + raise NotImplementedError("base_fee is not implemented by this provider") + @abstractmethod def send_call(self, txn: TransactionAPI) -> bytes: # Return value of function ... @@ -229,6 +264,18 @@ def gas_price(self) -> int: """ return self._web3.eth.generate_gas_price() # type: ignore + @property + def priority_fee(self) -> int: + """ + Returns the current max priority fee per gas in wei. + """ + return self._web3.eth.max_priority_fee + + @property + def base_fee(self) -> int: + block = self._web3.eth.get_block("latest") + return block.baseFeePerGas # type: ignore + def get_nonce(self, address: str) -> int: """ Returns the number of transactions sent from an address. diff --git a/src/ape/managers/compilers.py b/src/ape/managers/compilers.py index 0959ac7f9f..003c51fadd 100644 --- a/src/ape/managers/compilers.py +++ b/src/ape/managers/compilers.py @@ -4,12 +4,12 @@ from dataclassy import dataclass from ape.api.compiler import CompilerAPI +from ape.exceptions import CompilerError from ape.logging import logger from ape.plugins import PluginManager from ape.types import ContractType from ape.utils import cached_property -from ..exceptions import CompilerError from .config import ConfigManager diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index ee92849a45..3678c0e1ae 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -5,10 +5,10 @@ import requests from dataclassy import dataclass +from ape.exceptions import ProjectError from ape.types import Checksum, Compiler, ContractType, PackageManifest, Source from ape.utils import compute_checksum -from ..exceptions import ProjectError from .compilers import CompilerManager from .config import ConfigManager diff --git a/src/ape/utils.py b/src/ape/utils.py index 61ca5718b3..b95b5f8958 100644 --- a/src/ape/utils.py +++ b/src/ape/utils.py @@ -186,7 +186,7 @@ def extract_nested_value(root: Mapping, *args: str) -> Optional[Dict]: """ current_value: Any = root for arg in args: - if not isinstance(current_value, dict): + if not hasattr(current_value, "get"): return None current_value = current_value.get(arg) diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 859d3f7368..eb3f18d941 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Tuple, Type from eth_abi import decode_abi as abi_decode from eth_abi import encode_abi as abi_encode @@ -8,11 +8,19 @@ encode_transaction, serializable_unsigned_transaction_from_dict, ) -from eth_utils import keccak, to_bytes, to_int +from eth_typing import HexStr +from eth_utils import add_0x_prefix, keccak, to_bytes, to_int from hexbytes import HexBytes -from ape.api import ContractLog, EcosystemAPI, ReceiptAPI, TransactionAPI, TransactionStatusEnum -from ape.exceptions import DecodingError, SignatureError +from ape.api import ( + ContractLog, + EcosystemAPI, + ReceiptAPI, + TransactionAPI, + TransactionStatusEnum, + TransactionType, +) +from ape.exceptions import DecodingError, OutOfGasError, SignatureError, TransactionError from ape.types import ABI, AddressType NETWORKS = { @@ -25,8 +33,7 @@ } -# TODO: Fix this to add support for TypedTransaction -class Transaction(TransactionAPI): +class BaseTransaction(TransactionAPI): def is_valid(self) -> bool: return False @@ -47,7 +54,6 @@ def as_dict(self) -> dict: data["from"] = sender data["gas"] = data.pop("gas_limit") - data["gasPrice"] = data.pop("gas_price") # NOTE: Don't include signature data.pop("signature") @@ -75,7 +81,69 @@ def encode(self) -> bytes: return signed_txn +class StaticFeeTransaction(BaseTransaction): + """ + Transactions that are pre-EIP-1559 and use the ``gasPrice`` field. + """ + + gas_price: int = None # type: ignore + type: TransactionType = TransactionType.STATIC + + @property + def max_fee(self) -> int: + return (self.gas_limit or 0) * (self.gas_price or 0) + + @max_fee.setter + def max_fee(self, valie): + raise NotImplementedError("Max fee is not settable for static-fee transactions.") + + def as_dict(self): + data = super().as_dict() + if "gas_price" in data: + data["gasPrice"] = data.pop("gas_price") + + data.pop("type") + + return data + + +class DynamicFeeTransaction(BaseTransaction): + """ + Transactions that are post-EIP-1559 and use the ``maxFeePerGas`` + and ``maxPriorityFeePerGas`` fields. + """ + + max_fee: int = None # type: ignore + max_priority_fee: int = None # type: ignore + type: TransactionType = TransactionType.DYNAMIC + + def as_dict(self): + data = super().as_dict() + if "max_fee" in data: + data["maxFeePerGas"] = data.pop("max_fee") + if "max_priority_fee" in data: + data["maxPriorityFeePerGas"] = data.pop("max_priority_fee") + + data["type"] = data.pop("type").value + + return data + + class Receipt(ReceiptAPI): + def raise_for_status(self, txn: TransactionAPI): + """ + Raises :class`~ape.exceptions.OutOfGasError` when the + transaction failed and consumed all the gas. + + Raises :class:`~ape.exceptions.TransactionError` + when the transaction has a failing status otherwise. + """ + if txn.gas_limit and self.ran_out_of_gas(txn.gas_limit): + raise OutOfGasError() + elif self.status != TransactionStatusEnum.NO_ERROR: + txn_hash = HexBytes(self.txn_hash).hex() + raise TransactionError(message=f"Transaction '{txn_hash}' failed.") + @classmethod def decode(cls, data: dict) -> ReceiptAPI: return cls( # type: ignore @@ -90,7 +158,10 @@ def decode(cls, data: dict) -> ReceiptAPI: class Ethereum(EcosystemAPI): - transaction_class = Transaction + transaction_types = { + TransactionType.STATIC: StaticFeeTransaction, + TransactionType.DYNAMIC: DynamicFeeTransaction, + } receipt_class = Receipt def encode_calldata(self, abi: ABI, *args) -> bytes: @@ -111,15 +182,16 @@ def decode_calldata(self, abi: ABI, raw_data: bytes) -> Any: def encode_deployment( self, deployment_bytecode: bytes, abi: Optional[ABI], *args, **kwargs - ) -> Transaction: - txn = Transaction(**kwargs) # type: ignore + ) -> BaseTransaction: + kwargs["type"], txn_type = self._extract_transaction_type(**kwargs) + txn = txn_type(**kwargs) # type: ignore txn.data = deployment_bytecode # Encode args, if there are any if abi: txn.data += self.encode_calldata(abi, *args) - return txn + return txn # type: ignore def encode_transaction( self, @@ -127,14 +199,27 @@ def encode_transaction( abi: ABI, *args, **kwargs, - ) -> Transaction: - txn = Transaction(receiver=address, **kwargs) # type: ignore + ) -> BaseTransaction: + kwargs["type"], txn_type = self._extract_transaction_type(**kwargs) + txn = txn_type(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 + return txn # type: ignore + + def _extract_transaction_type(self, **kwargs) -> Tuple[TransactionType, Type[TransactionAPI]]: + if "type" in kwargs: + type_arg = HexStr(str(kwargs["type"])) + version_str = str(add_0x_prefix(type_arg)) + version = TransactionType(version_str) + elif "gas_price" in kwargs: + version = TransactionType.STATIC + else: + version = TransactionType.DYNAMIC + + return version, self.transaction_types[version] def decode_event(self, abi: ABI, receipt: "ReceiptAPI") -> "ContractLog": filter_id = keccak(to_bytes(text=abi.selector)) diff --git a/src/ape_geth/providers.py b/src/ape_geth/providers.py index dd6f91df01..7bdbfe2f15 100644 --- a/src/ape_geth/providers.py +++ b/src/ape_geth/providers.py @@ -16,16 +16,9 @@ from web3.middleware import geth_poa_middleware from web3.types import NodeInfo -from ape.api import ReceiptAPI, TransactionAPI +from ape.api import ReceiptAPI, TransactionAPI, Web3Provider from ape.api.config import ConfigItem -from ape.api.providers import Web3Provider -from ape.exceptions import ( - ContractLogicError, - OutOfGasError, - ProviderError, - TransactionError, - VirtualMachineError, -) +from ape.exceptions import ContractLogicError, ProviderError, TransactionError, VirtualMachineError from ape.logging import logger from ape.utils import extract_nested_value, gas_estimation_error_message, generate_dev_accounts @@ -239,9 +232,7 @@ def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: except ValueError as err: raise _get_vm_error(err) from err - if txn.gas_limit is not None and receipt.ran_out_of_gas(txn.gas_limit): - raise OutOfGasError() - + receipt.raise_for_status(txn) return receipt diff --git a/src/ape_test/providers.py b/src/ape_test/providers.py index 8b80612545..61ee9f8345 100644 --- a/src/ape_test/providers.py +++ b/src/ape_test/providers.py @@ -32,7 +32,16 @@ def chain_id(self) -> int: @property def gas_price(self) -> int: - # NOTE: Test chain doesn't care about gas prices + return self.base_fee # no miner tip + + @property + def priority_fee(self) -> int: + """Returns 0 because test chains do not care about priority fees.""" + return 0 + + @property + def base_fee(self) -> int: + """Returns 0 because test chains do not care about base fees.""" return 0 def get_nonce(self, address: str) -> int: diff --git a/tests/functional/api/test_accounts.py b/tests/functional/api/test_accounts.py index 03ce3da608..80bc5e1537 100644 --- a/tests/functional/api/test_accounts.py +++ b/tests/functional/api/test_accounts.py @@ -1,6 +1,6 @@ import pytest -from ape.api import TransactionAPI +from ape.api import TransactionAPI, TransactionType from ape.api.accounts import AccountAPI from ape.exceptions import AccountsError from ape.types import AddressType @@ -37,7 +37,7 @@ def test_account_api_can_sign(mock_account_container_api, mock_provider_api): class TestAccountAPI: - def test_txn_nonce_less_than_accounts_raise_accounts_error( + def test_txn_nonce_less_than_accounts_raise_tx_error( self, mocker, mock_provider_api, test_account_api_can_sign ): mock_transaction = mocker.MagicMock(spec=TransactionAPI) @@ -56,6 +56,8 @@ def test_not_enough_funds_raises_error( ): mock_transaction = mocker.MagicMock(spec=TransactionAPI) mock_provider_api.get_nonce.return_value = mock_transaction.nonce = 0 + mock_transaction.type = TransactionType.STATIC + mock_transaction.gas_price = 0 # Transaction costs are greater than balance mock_transaction.total_transfer_value = 1000000 @@ -77,6 +79,8 @@ def test_transaction_not_signed_raises_error( mock_transaction = mocker.MagicMock(spec=TransactionAPI) mock_provider_api.get_nonce.return_value = mock_transaction.nonce = 0 mock_transaction.total_transfer_value = mock_provider_api.get_balance.return_value = 1000000 + mock_transaction.type = TransactionType.STATIC + mock_transaction.gas_price = 0 with pytest.raises(AccountsError) as err: test_account_api_no_sign.call(mock_transaction) @@ -87,6 +91,8 @@ def test_transaction_when_no_gas_limit_calls_estimate_gas_cost( self, mocker, mock_provider_api, test_account_api_can_sign ): mock_transaction = mocker.MagicMock(spec=TransactionAPI) + mock_transaction.type = TransactionType.STATIC + mock_transaction.gas_price = 0 mock_transaction.gas_limit = None # Causes estimate_gas_cost to get called mock_provider_api.get_nonce.return_value = mock_transaction.nonce = 0 mock_transaction.total_transfer_value = mock_provider_api.get_balance.return_value = 1000000 diff --git a/tests/functional/ethereum/test_ecosystem.py b/tests/functional/ethereum/test_ecosystem.py index 5870a5bdec..7362f68831 100644 --- a/tests/functional/ethereum/test_ecosystem.py +++ b/tests/functional/ethereum/test_ecosystem.py @@ -1,12 +1,31 @@ -from ape_ethereum.ecosystem import Transaction +import pytest +from ape.exceptions import OutOfGasError +from ape_ethereum.ecosystem import BaseTransaction, Receipt, TransactionStatusEnum -class TestTransaction: + +class TestBaseTransaction: def test_as_dict_excludes_none_values(self): - txn = Transaction() + txn = BaseTransaction() txn.value = 1000000 actual = txn.as_dict() assert "value" in actual txn.value = None actual = txn.as_dict() assert "value" not in actual + + +class TestReceipt: + def test_raise_for_status_out_of_gas_error(self, mocker): + gas_limit = 100000 + txn = BaseTransaction() + txn.gas_limit = gas_limit + receipt = Receipt( + txn_hash="", + gas_used=gas_limit, + status=TransactionStatusEnum.FAILING, + gas_price=0, + block_number=0, + ) + with pytest.raises(OutOfGasError): + receipt.raise_for_status(txn) diff --git a/tests/functional/http/test_providers.py b/tests/functional/http/test_providers.py index bc413f9305..6534dd3552 100644 --- a/tests/functional/http/test_providers.py +++ b/tests/functional/http/test_providers.py @@ -4,7 +4,7 @@ from web3.exceptions import ContractLogicError as Web3ContractLogicError from ape.api import ReceiptAPI, TransactionStatusEnum -from ape.exceptions import ContractLogicError, OutOfGasError, TransactionError +from ape.exceptions import ContractLogicError, TransactionError from ape_geth import GethProvider _TEST_REVERT_REASON = "TEST REVERT REASON." @@ -50,27 +50,6 @@ def test_send_when_web3_error_raises_transaction_error( assert web3_error_data["message"] in str(err.value) - def test_send_transaction_out_of_gas_error( - self, mock_web3, mock_network_api, mock_config_item, mock_transaction - ): - provider = GethProvider( - name="test", - network=mock_network_api, - config=mock_config_item, - provider_settings={}, - data_folder=Path("."), - request_header="", - ) - provider._web3 = mock_web3 - - gas_limit = 30000 - mock_transaction.gas_limit = gas_limit - mock_network_api.ecosystem.receipt_class = _create_mock_receipt( - TransactionStatusEnum.FAILING, gas_used=gas_limit - ) - with pytest.raises(OutOfGasError): - provider.send_transaction(mock_transaction) - def test_send_transaction_reverts_from_contract_logic( self, mock_web3, mock_network_api, mock_config_item, mock_transaction ):