diff --git a/nucypher/blockchain/eth/clients.py b/nucypher/blockchain/eth/clients.py index af14b33d2..1194a14a3 100644 --- a/nucypher/blockchain/eth/clients.py +++ b/nucypher/blockchain/eth/clients.py @@ -36,7 +36,7 @@ from geth.chain import ( is_ropsten_chain ) from geth.process import BaseGethProcess -from typing import Union +from typing import Union, Optional from web3 import Web3 from web3.contract import Contract from web3.types import Wei, TxReceipt @@ -250,10 +250,13 @@ class EthereumClient: return self.w3.eth.getBalance(account) def inject_middleware(self, middleware, **kwargs): - return self.w3.middleware_onion.inject(middleware, **kwargs) + self.w3.middleware_onion.inject(middleware, **kwargs) def add_middleware(self, middleware): - return self.w3.middleware_onion.add(middleware) + self.w3.middleware_onion.add(middleware) + + def set_gas_strategy(self, gas_strategy): + self.w3.eth.setGasPriceStrategy(gas_strategy) @property def chain_id(self) -> int: @@ -273,8 +276,18 @@ class EthereumClient: @property def gas_price(self) -> Wei: + """ + Returns client's gas price. Underneath, it uses the eth_gasPrice JSON-RPC method + """ return self.w3.eth.gasPrice + def gas_price_for_transaction(self, transaction=None) -> Wei: + """ + Obtains a gas price via the current gas strategy, if any; otherwise, it resorts to the client's gas price. + This method mirrors the behavior of web3._utils.transactions when building transactions. + """ + return self.w3.eth.generateGasPrice(transaction) or self.gas_price + @property def block_number(self) -> BlockNumber: return self.w3.eth.blockNumber diff --git a/nucypher/blockchain/eth/interfaces.py b/nucypher/blockchain/eth/interfaces.py index 52ef1cc6a..0cfadd05f 100644 --- a/nucypher/blockchain/eth/interfaces.py +++ b/nucypher/blockchain/eth/interfaces.py @@ -40,7 +40,7 @@ from web3.exceptions import TimeExhausted, ValidationError from web3.gas_strategies import time_based from web3.middleware import geth_poa_middleware -from nucypher.blockchain.eth.clients import EthereumClient, POA_CHAINS +from nucypher.blockchain.eth.clients import EthereumClient, POA_CHAINS, InfuraClient from nucypher.blockchain.eth.decorators import validate_checksum_address from nucypher.blockchain.eth.providers import ( _get_auto_provider, @@ -57,6 +57,7 @@ from nucypher.blockchain.eth.sol.compile import SolidityCompiler from nucypher.blockchain.eth.utils import get_transaction_name, prettify_eth_amount from nucypher.characters.control.emitters import JSONRPCStdoutEmitter, StdoutEmitter from nucypher.utilities.logging import GlobalLoggerSettings, Logger +from nucypher.utilities.datafeeds import datafeed_fallback_gas_price_strategy Web3Providers = Union[IPCProvider, WebsocketProvider, HTTPProvider, EthereumTester] @@ -73,7 +74,7 @@ class BlockchainInterface: TIMEOUT = 600 # seconds # TODO: Correlate with the gas strategy - #2070 - DEFAULT_GAS_STRATEGY = 'medium' + DEFAULT_GAS_STRATEGY = 'fast' GAS_STRATEGIES = {'glacial': time_based.glacial_gas_price_strategy, # 24h 'slow': time_based.slow_gas_price_strategy, # 1h 'medium': time_based.medium_gas_price_strategy, # 5m @@ -253,32 +254,43 @@ class BlockchainInterface: return self.client.is_connected @classmethod - def get_gas_strategy(cls, gas_strategy: Union[str, Callable]) -> Callable: + def get_gas_strategy(cls, gas_strategy: Union[str, Callable] = None) -> Callable: try: gas_strategy = cls.GAS_STRATEGIES[gas_strategy] except KeyError: - if gas_strategy and not callable(gas_strategy): - raise ValueError(f"{gas_strategy} must be callable to be a valid gas strategy.") + if gas_strategy: + if not callable(gas_strategy): + raise ValueError(f"{gas_strategy} must be callable to be a valid gas strategy.") else: gas_strategy = cls.GAS_STRATEGIES[cls.DEFAULT_GAS_STRATEGY] return gas_strategy def attach_middleware(self): + chain_id = int(self.client.chain_id) if self.poa is None: # If POA is not set explicitly, try to autodetect from chain id - chain_id = int(self.client.chain_id) self.poa = chain_id in POA_CHAINS - self.log.debug(f'Autodetecting POA chain ({self.client.chain_name})') + + self.log.debug(f'Ethereum chain: {self.client.chain_name} (chain_id={chain_id}, poa={self.poa})') # For use with Proof-Of-Authority test-blockchains if self.poa is True: self.log.debug('Injecting POA middleware at layer 0') self.client.inject_middleware(geth_poa_middleware, layer=0) - # Gas Price Strategy - self.client.w3.eth.setGasPriceStrategy(self.gas_strategy) - self.client.w3.middleware_onion.add(middleware.time_based_cache_middleware) - self.client.w3.middleware_onion.add(middleware.latest_block_based_cache_middleware) - self.client.w3.middleware_onion.add(middleware.simple_cache_middleware) + # Gas Price Strategy: + # Bundled web3 strategies are too expensive for Infura (it takes ~1 minute to get a price), + # so we use external gas price oracles, instead (see #2139) + if isinstance(self.client, InfuraClient): + gas_strategy = datafeed_fallback_gas_price_strategy + else: + gas_strategy = self.gas_strategy + self.client.set_gas_strategy(gas_strategy=gas_strategy) + gwei_gas_price = Web3.fromWei(self.client.gas_price_for_transaction(), 'gwei') + self.log.debug(f"Currently, our gas strategy returns a gas price of {gwei_gas_price} gwei") + + self.client.add_middleware(middleware.time_based_cache_middleware) + self.client.add_middleware(middleware.latest_block_based_cache_middleware) + self.client.add_middleware(middleware.simple_cache_middleware) def connect(self): @@ -474,8 +486,7 @@ class BlockchainInterface: base_payload = {'chainId': int(self.client.chain_id), 'nonce': self.client.w3.eth.getTransactionCount(sender_address, 'pending'), - 'from': sender_address, - 'gasPrice': self.client.gas_price} + 'from': sender_address} # Aggregate if not payload: @@ -530,17 +541,22 @@ class BlockchainInterface: # TODO: Show the USD Price: https://api.coinmarketcap.com/v1/ticker/ethereum/ price = transaction_dict['gasPrice'] + price_gwei = Web3.fromWei(price, 'gwei') cost_wei = price * transaction_dict['gas'] - cost = Web3.fromWei(cost_wei, 'gwei') + cost = Web3.fromWei(cost_wei, 'ether') + if self.transacting_power.is_device: - emitter.message(f'Confirm transaction {transaction_name} on hardware wallet... ({cost} gwei @ {price})', color='yellow') + emitter.message(f'Confirm transaction {transaction_name} on hardware wallet... ' + f'({cost} ETH @ {price_gwei} gwei)', + color='yellow') signed_raw_transaction = self.transacting_power.sign_transaction(transaction_dict) # # Broadcast # - emitter.message(f'Broadcasting {transaction_name} Transaction ({cost} gwei @ {price})...', color='yellow') + emitter.message(f'Broadcasting {transaction_name} Transaction ({cost} ETH @ {price_gwei} gwei)...', + color='yellow') try: txhash = self.client.send_raw_transaction(signed_raw_transaction) # <--- BROADCAST except (TestTransactionFailed, ValueError) as error: diff --git a/nucypher/characters/chaotic.py b/nucypher/characters/chaotic.py index 66acc3050..199f4672c 100644 --- a/nucypher/characters/chaotic.py +++ b/nucypher/characters/chaotic.py @@ -342,7 +342,7 @@ class Felix(Character, NucypherTokenActor): transaction = {'to': recipient_address, 'from': self.checksum_address, 'value': ether, - 'gasPrice': self.blockchain.client.gas_price} + 'gasPrice': self.blockchain.client.gas_price_for_transaction()} transaction_dict = self.blockchain.build_payload(sender_address=self.checksum_address, payload=transaction, diff --git a/nucypher/cli/commands/ursula.py b/nucypher/cli/commands/ursula.py index e42bf0ed4..98e6f6d95 100644 --- a/nucypher/cli/commands/ursula.py +++ b/nucypher/cli/commands/ursula.py @@ -105,7 +105,6 @@ class UrsulaConfigOptions: message=f"--registry-filepath cannot be used in federated mode.") eth_node = NO_BLOCKCHAIN_CONNECTION - provider_uri = provider_uri if geth: eth_node = get_geth_provider_process() provider_uri = eth_node.provider_uri(scheme='file') diff --git a/nucypher/utilities/datafeeds.py b/nucypher/utilities/datafeeds.py new file mode 100644 index 000000000..4c07ffe18 --- /dev/null +++ b/nucypher/utilities/datafeeds.py @@ -0,0 +1,120 @@ +""" + This file is part of nucypher. + + nucypher is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + nucypher is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with nucypher. If not, see . +""" +from abc import ABC, abstractmethod +from typing import Optional + +import requests +from web3 import Web3 +from web3.gas_strategies.rpc import rpc_gas_price_strategy +from web3.types import Wei, TxParams + + +class Datafeed(ABC): + + class DatafeedError(RuntimeError): + """Base class for exceptions concerning Datafeeds""" + + name = NotImplemented + api_url = NotImplemented # TODO: Deal with API keys + + def _probe_feed(self): + try: + response = requests.get(self.api_url) + except requests.exceptions.ConnectionError as e: + error = f"Failed to probe feed at {self.api_url}: {str(e)}" + raise self.DatafeedError(error) + + if response.status_code != 200: + error = f"Failed to probe feed at {self.api_url} with status code {response.status_code}" + raise self.DatafeedError(error) + + self._raw_data = response.json() + + def __repr__(self): + return f"{self.name} ({self.api_url})" + + +class EthereumGasPriceDatafeed(Datafeed): + """Base class for Ethereum gas price data feeds""" + + _speed_names = NotImplemented + _default_speed = NotImplemented + + @abstractmethod + def _parse_gas_prices(self): + return NotImplementedError + + def get_gas_price(self, speed: Optional[str] = None) -> Wei: + speed = speed or self._default_speed + self._parse_gas_prices() + gas_price_wei = Wei(self.gas_prices[speed]) + return gas_price_wei + + @classmethod + def construct_gas_strategy(cls): + def gas_price_strategy(web3: Web3, transaction_params: TxParams = None) -> Wei: + feed = cls() + gas_price = feed.get_gas_price() + return gas_price + return gas_price_strategy + + +class EtherchainGasPriceDatafeed(EthereumGasPriceDatafeed): + """Gas price datafeed from Etherchain""" + + name = "Etherchain datafeed" + api_url = "https://www.etherchain.org/api/gasPriceOracle" + _speed_names = ('safeLow', 'standard', 'fast', 'fastest') + _default_speed = 'fast' + + def _parse_gas_prices(self): + self._probe_feed() + self.gas_prices = {k: int(Web3.toWei(v, 'gwei')) for k, v in self._raw_data.items()} + + +class UpvestGasPriceDatafeed(EthereumGasPriceDatafeed): + """Gas price datafeed from Upvest""" + + name = "Upvest datafeed" + api_url = "https://fees.upvest.co/estimate_eth_fees" + _speed_names = ('slow', 'medium', 'fast', 'fastest') + _default_speed = 'fastest' + + def _parse_gas_prices(self): + self._probe_feed() + self.gas_prices = {k: int(Web3.toWei(v, 'gwei')) for k, v in self._raw_data['estimates'].items()} + + +def datafeed_fallback_gas_price_strategy(web3: Web3, transaction_params: TxParams = None) -> Wei: + feeds = (EtherchainGasPriceDatafeed, UpvestGasPriceDatafeed) + + for gas_price_feed_class in feeds: + try: + gas_strategy = gas_price_feed_class.construct_gas_strategy() + gas_price = gas_strategy(web3, transaction_params) + except Datafeed.DatafeedError: + continue + else: + return gas_price + else: + # Worst-case scenario, we get the price from the ETH node itself + return rpc_gas_price_strategy(web3, transaction_params) + + + +# TODO: We can implement here other datafeeds, like the ETH/USD (e.g., https://api.coinmarketcap.com/v1/ticker/ethereum/) +# suggested in a comment in nucypher.blockchain.eth.interfaces.BlockchainInterface#sign_and_broadcast_transaction diff --git a/tests/acceptance/blockchain/actors/test_multisig_actors.py b/tests/acceptance/blockchain/actors/test_multisig_actors.py index 4eeb63484..4012b74ea 100644 --- a/tests/acceptance/blockchain/actors/test_multisig_actors.py +++ b/tests/acceptance/blockchain/actors/test_multisig_actors.py @@ -16,7 +16,7 @@ along with nucypher. If not, see . """ import pytest -from unittest.mock import PropertyMock, patch +from unittest.mock import patch from nucypher.blockchain.eth.actors import Trustee from nucypher.blockchain.eth.agents import MultiSigAgent @@ -39,11 +39,8 @@ def test_trustee_proposes_multisig_management_operations(testerchain, test_regis trustee = Trustee(checksum_address=trustee_address, registry=test_registry) # Propose changing threshold - - # FIXME: I had to mock the gas_price property of EthereumTesterClient because I'm unable to set a free gas strategy - with patch('nucypher.blockchain.eth.clients.EthereumTesterClient.gas_price', - new_callable=PropertyMock) as mock_ethtester: - mock_ethtester.return_value = 0 + free_payload = {'nonce': 0, 'from': multisig_agent.contract_address, 'gasPrice': 0} + with patch.object(testerchain, 'build_payload', return_value=free_payload): proposal = trustee.propose_changing_threshold(new_threshold=1) assert proposal.trustee_address == trustee_address @@ -57,9 +54,7 @@ def test_trustee_proposes_multisig_management_operations(testerchain, test_regis # Propose adding new owner new_owner = testerchain.unassigned_accounts[4] - with patch('nucypher.blockchain.eth.clients.EthereumTesterClient.gas_price', - new_callable=PropertyMock) as mock_ethtester: - mock_ethtester.return_value = 0 + with patch.object(testerchain, 'build_payload', return_value=free_payload): proposal = trustee.propose_adding_owner(new_owner_address=new_owner, evidence=None) assert proposal.trustee_address == trustee_address @@ -73,9 +68,7 @@ def test_trustee_proposes_multisig_management_operations(testerchain, test_regis # Propose removing owner evicted_owner = testerchain.unassigned_accounts[1] - with patch('nucypher.blockchain.eth.clients.EthereumTesterClient.gas_price', - new_callable=PropertyMock) as mock_ethtester: - mock_ethtester.return_value = 0 + with patch.object(testerchain, 'build_payload', return_value=free_payload): proposal = trustee.propose_removing_owner(evicted_owner) assert proposal.trustee_address == trustee_address diff --git a/tests/acceptance/blockchain/interfaces/test_chains.py b/tests/acceptance/blockchain/interfaces/test_chains.py index 465cde3fa..c12ac1bd0 100644 --- a/tests/acceptance/blockchain/interfaces/test_chains.py +++ b/tests/acceptance/blockchain/interfaces/test_chains.py @@ -32,7 +32,7 @@ from nucypher.crypto.powers import TransactingPower # Prevents TesterBlockchain to be picked up by py.test as a test class from tests.fixtures import _make_testerchain from tests.mock.interfaces import MockBlockchain -from tests.utils.blockchain import TesterBlockchain as _TesterBlockchain +from tests.utils.blockchain import TesterBlockchain as _TesterBlockchain, free_gas_price_strategy from tests.constants import (DEVELOPMENT_ETH_AIRDROP_AMOUNT, INSECURE_DEVELOPMENT_PASSWORD, NUMBER_OF_ETH_TEST_ACCOUNTS, NUMBER_OF_STAKERS_IN_BLOCKCHAIN_TESTS, NUMBER_OF_URSULAS_IN_BLOCKCHAIN_TESTS) @@ -105,7 +105,9 @@ def test_multiversion_contract(): SourceDirs(root_dir, {v1_dir})]) # Prepare chain - blockchain_interface = BlockchainDeployerInterface(provider_uri='tester://pyevm/2', compiler=solidity_compiler) + blockchain_interface = BlockchainDeployerInterface(provider_uri='tester://pyevm/2', + compiler=solidity_compiler, + gas_strategy=free_gas_price_strategy) blockchain_interface.connect() origin = blockchain_interface.client.accounts[0] blockchain_interface.transacting_power = TransactingPower(password=INSECURE_DEVELOPMENT_PASSWORD, account=origin) diff --git a/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py b/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py index 23cb38014..407878e36 100644 --- a/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py +++ b/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py @@ -22,7 +22,6 @@ import maya import os import pytest import tempfile -from twisted.logger import Logger from web3 import Web3 from nucypher.blockchain.eth.actors import Staker diff --git a/tests/contracts/test_contracts_upgradeability.py b/tests/contracts/test_contracts_upgradeability.py index 082a185ac..8be097daf 100644 --- a/tests/contracts/test_contracts_upgradeability.py +++ b/tests/contracts/test_contracts_upgradeability.py @@ -28,6 +28,7 @@ from nucypher.blockchain.eth.registry import InMemoryContractRegistry from nucypher.blockchain.eth.sol.compile import SolidityCompiler, SourceDirs from nucypher.crypto.powers import TransactingPower from tests.constants import INSECURE_DEVELOPMENT_PASSWORD +from tests.utils.blockchain import free_gas_price_strategy USER = "nucypher" REPO = "nucypher" @@ -93,7 +94,7 @@ def deploy_earliest_contract(blockchain_interface: BlockchainDeployerInterface, pass # Skip errors related to initialization -def test_upgradeability(temp_dir_path, token_economics): +def test_upgradeability(temp_dir_path): # Prepare remote source for compilation download_github_dir(GITHUB_SOURCE_LINK, temp_dir_path) solidity_compiler = SolidityCompiler(source_dirs=[SourceDirs(SolidityCompiler.default_contract_dir()), @@ -102,7 +103,9 @@ def test_upgradeability(temp_dir_path, token_economics): # Prepare the blockchain provider_uri = 'tester://pyevm/2' try: - blockchain_interface = BlockchainDeployerInterface(provider_uri=provider_uri, compiler=solidity_compiler) + blockchain_interface = BlockchainDeployerInterface(provider_uri=provider_uri, + compiler=solidity_compiler, + gas_strategy=free_gas_price_strategy) blockchain_interface.connect() origin = blockchain_interface.client.accounts[0] BlockchainInterfaceFactory.register_interface(interface=blockchain_interface) diff --git a/tests/fixtures.py b/tests/fixtures.py index 068882036..7cc669076 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -29,7 +29,6 @@ from click.testing import CliRunner from datetime import datetime, timedelta from eth_utils import to_checksum_address from io import StringIO -from twisted.logger import Logger from typing import Tuple from umbral import pre from umbral.curvebn import CurveBN diff --git a/tests/integration/cli/test_ursula_local_keystore_cli_functionality.py b/tests/integration/cli/test_ursula_local_keystore_cli_functionality.py index 8a92570f7..3c12c4219 100644 --- a/tests/integration/cli/test_ursula_local_keystore_cli_functionality.py +++ b/tests/integration/cli/test_ursula_local_keystore_cli_functionality.py @@ -30,7 +30,7 @@ from nucypher.config.constants import ( NUCYPHER_ENVVAR_WORKER_ETH_PASSWORD, TEMPORARY_DOMAIN ) -from tests.constants import MOCK_IP_ADDRESS, TEST_PROVIDER_URI +from tests.constants import MOCK_IP_ADDRESS from tests.utils.ursula import MOCK_URSULA_STARTING_PORT @@ -63,7 +63,7 @@ def test_ursula_init_with_local_keystore_signer(click_runner, '--network', TEMPORARY_DOMAIN, '--worker-address', worker_account.address, '--config-root', custom_filepath, - '--provider', TEST_PROVIDER_URI, + '--provider', mock_testerchain.provider_uri, '--rest-host', MOCK_IP_ADDRESS, '--rest-port', MOCK_URSULA_STARTING_PORT, diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1c15263c5..a92ac68d0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -15,13 +15,10 @@ You should have received a copy of the GNU Affero General Public License along with nucypher. If not, see . """ - -import click import lmdb import os import pytest from contextlib import contextmanager -from eth_account import Account from eth_account.account import Account from functools import partial @@ -86,10 +83,12 @@ class TestLMDBEnv: def path(self): return self.db_path + @pytest.fixture(autouse=True) def JIT_lmdb_env(monkeypatch): monkeypatch.setattr("lmdb.open", TestLMDBEnv) + @pytest.fixture(autouse=True) def reduced_memory_page_lmdb(monkeypatch): monkeypatch.setattr(Datastore, "LMDB_MAP_SIZE", 10_000_000) diff --git a/tests/mock/agents.py b/tests/mock/agents.py index 47e289e8d..08c287574 100644 --- a/tests/mock/agents.py +++ b/tests/mock/agents.py @@ -27,8 +27,10 @@ from nucypher.blockchain.eth.agents import Agent, ContractAgency, EthereumContra from nucypher.blockchain.eth.constants import NULL_ADDRESS from nucypher.blockchain.eth.interfaces import BlockchainInterfaceFactory from tests.constants import MOCK_PROVIDER_URI +from tests.utils.blockchain import free_gas_price_strategy -MOCK_TESTERCHAIN = BlockchainInterfaceFactory.get_or_create_interface(provider_uri=MOCK_PROVIDER_URI) +MOCK_TESTERCHAIN = BlockchainInterfaceFactory.get_or_create_interface(provider_uri=MOCK_PROVIDER_URI, + gas_strategy=free_gas_price_strategy) CURRENT_BLOCK = MOCK_TESTERCHAIN.w3.eth.getBlock(block_identifier='latest') diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 000000000..d35aed81a --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,27 @@ +""" + This file is part of nucypher. + + nucypher is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + nucypher is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with nucypher. If not, see . +""" + +import pytest + +from tests.mock.interfaces import MockEthereumClient + + +@pytest.fixture(scope='function') +def mock_ethereum_client(mocker): + web3_mock = mocker.Mock() + mock_client = MockEthereumClient(w3=web3_mock) + return mock_client diff --git a/tests/unit/test_block_confirmations.py b/tests/unit/test_block_confirmations.py index 20053e978..da27a9107 100644 --- a/tests/unit/test_block_confirmations.py +++ b/tests/unit/test_block_confirmations.py @@ -24,13 +24,6 @@ from web3.exceptions import TransactionNotFound, TimeExhausted from tests.mock.interfaces import MockEthereumClient -@pytest.fixture(scope='function') -def mock_ethereum_client(mocker): - web3_mock = mocker.Mock() - mock_client = MockEthereumClient(w3=web3_mock) - return mock_client - - @pytest.fixture() def receipt(): block_number_of_my_tx = 42 diff --git a/tests/unit/test_blockchain_interface.py b/tests/unit/test_blockchain_interface.py new file mode 100644 index 000000000..fe93e3896 --- /dev/null +++ b/tests/unit/test_blockchain_interface.py @@ -0,0 +1,43 @@ +""" + This file is part of nucypher. + + nucypher is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + nucypher is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with nucypher. If not, see . +""" +from web3.gas_strategies import time_based + +from nucypher.blockchain.eth.interfaces import BlockchainInterface + + +def test_get_gas_strategy(): + + # Testing Web3's bundled time-based gas strategies + bundled_gas_strategies = {'glacial': time_based.glacial_gas_price_strategy, # 24h + 'slow': time_based.slow_gas_price_strategy, # 1h + 'medium': time_based.medium_gas_price_strategy, # 5m + 'fast': time_based.fast_gas_price_strategy # 60s + } + for gas_strategy_name, expected_gas_strategy in bundled_gas_strategies.items(): + gas_strategy = BlockchainInterface.get_gas_strategy(gas_strategy_name) + assert expected_gas_strategy == gas_strategy + assert callable(gas_strategy) + + # Passing a callable gas strategy + callable_gas_strategy = time_based.glacial_gas_price_strategy + assert callable_gas_strategy == BlockchainInterface.get_gas_strategy(callable_gas_strategy) + + # Passing None should retrieve the default gas strategy + assert BlockchainInterface.DEFAULT_GAS_STRATEGY == 'fast' + default = bundled_gas_strategies[BlockchainInterface.DEFAULT_GAS_STRATEGY] + gas_strategy = BlockchainInterface.get_gas_strategy() + assert default == gas_strategy diff --git a/tests/unit/test_datafeeds.py b/tests/unit/test_datafeeds.py new file mode 100644 index 000000000..88bc47d38 --- /dev/null +++ b/tests/unit/test_datafeeds.py @@ -0,0 +1,148 @@ +""" +This file is part of nucypher. + +nucypher is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +nucypher is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with nucypher. If not, see . +""" + +from unittest.mock import patch + +import pytest +from requests.exceptions import ConnectionError +from web3 import Web3 + +from nucypher.utilities.datafeeds import ( + EtherchainGasPriceDatafeed, + Datafeed, + datafeed_fallback_gas_price_strategy, + UpvestGasPriceDatafeed +) + +etherchain_json = { + "safeLow": "99.0", + "standard": "105.0", + "fast": "108.0", + "fastest": "119.9" +} + +upvest_json = { + "success": True, + "updated": "2020-08-19T02:38:00.172Z", + "estimates": { + "fastest": 105.2745, + "fast": 97.158, + "medium": 91.424, + "slow": 87.19 + } +} + + +def test_probe_datafeed(mocker): + + feed = Datafeed() + feed.api_url = "http://foo.bar" + + with patch('requests.get', side_effect=ConnectionError("Bad connection")) as mocked_get: + with pytest.raises(Datafeed.DatafeedError, match="Bad connection"): + feed._probe_feed() + mocked_get.assert_called_once_with(feed.api_url) + + bad_response = mocker.Mock() + bad_response.status_code = 400 + with patch('requests.get') as mocked_get: + mocked_get.return_value = bad_response + with pytest.raises(Datafeed.DatafeedError, match="status code 400"): + feed._probe_feed() + mocked_get.assert_called_once_with(feed.api_url) + + json = {'foo': 'bar'} + good_response = mocker.Mock() + good_response.status_code = 200 + good_response.json = mocker.Mock(return_value=json) + with patch('requests.get') as mocked_get: + mocked_get.return_value = good_response + feed._probe_feed() + mocked_get.assert_called_once_with(feed.api_url) + assert feed._raw_data == json + + +def test_etherchain(): + feed = EtherchainGasPriceDatafeed() + + assert set(feed._speed_names).issubset(etherchain_json.keys()) + assert feed._default_speed in etherchain_json.keys() + + with patch.object(feed, '_probe_feed'): + feed._raw_data = etherchain_json + assert feed.get_gas_price('safeLow') == Web3.toWei(99.0, 'gwei') + assert feed.get_gas_price('standard') == Web3.toWei(105.0, 'gwei') + assert feed.get_gas_price('fast') == Web3.toWei(108.0, 'gwei') + assert feed.get_gas_price('fastest') == Web3.toWei(119.9, 'gwei') + assert feed.get_gas_price() == feed.get_gas_price('fast') # Default + parsed_gas_prices = feed.gas_prices + + with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed._parse_gas_prices', autospec=True): + EtherchainGasPriceDatafeed.gas_prices = dict() + with patch.dict(EtherchainGasPriceDatafeed.gas_prices, values=parsed_gas_prices): + gas_strategy = feed.construct_gas_strategy() + assert gas_strategy("web3", "tx") == Web3.toWei(108.0, 'gwei') + + +def test_upvest(): + feed = UpvestGasPriceDatafeed() + + assert set(feed._speed_names).issubset(upvest_json['estimates'].keys()) + assert feed._default_speed in upvest_json['estimates'].keys() + + with patch.object(feed, '_probe_feed'): + feed._raw_data = upvest_json + assert feed.get_gas_price('slow') == Web3.toWei(87.19, 'gwei') + assert feed.get_gas_price('medium') == Web3.toWei(91.424, 'gwei') + assert feed.get_gas_price('fast') == Web3.toWei(97.158, 'gwei') + assert feed.get_gas_price('fastest') == Web3.toWei(105.2745, 'gwei') + assert feed.get_gas_price() == feed.get_gas_price('fastest') # Default + parsed_gas_prices = feed.gas_prices + + with patch('nucypher.utilities.datafeeds.UpvestGasPriceDatafeed._parse_gas_prices', autospec=True): + UpvestGasPriceDatafeed.gas_prices = dict() + with patch.dict(UpvestGasPriceDatafeed.gas_prices, values=parsed_gas_prices): + gas_strategy = feed.construct_gas_strategy() + assert gas_strategy("web3", "tx") == Web3.toWei(105.2745, 'gwei') + + +def test_datafeed_fallback_gas_price_strategy(): + + mocked_gas_price = 0xFABADA + + def mock_gas_strategy(web3, tx=None): + return mocked_gas_price + + # In normal circumstances, the first datafeed (Etherchain) will return the gas price + with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed.construct_gas_strategy', + return_value=mock_gas_strategy): + assert datafeed_fallback_gas_price_strategy("web3", "tx") == mocked_gas_price + + # If the first datafeed in the chain fails, we resort to the second one + with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed._probe_feed', + side_effect=Datafeed.DatafeedError): + with patch('nucypher.utilities.datafeeds.UpvestGasPriceDatafeed.construct_gas_strategy', + return_value=mock_gas_strategy): + assert datafeed_fallback_gas_price_strategy("web3", "tx") == mocked_gas_price + + # If both datafeeds fail, we fallback to the rpc_gas_price_strategy + with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed._probe_feed', + side_effect=Datafeed.DatafeedError): + with patch('nucypher.utilities.datafeeds.UpvestGasPriceDatafeed._probe_feed', + side_effect=Datafeed.DatafeedError): + with patch('nucypher.utilities.datafeeds.rpc_gas_price_strategy', side_effect=mock_gas_strategy): + assert datafeed_fallback_gas_price_strategy("web3", "tx") == mocked_gas_price diff --git a/tests/unit/test_web3_clients.py b/tests/unit/test_web3_clients.py index b2a190eb2..956a18f8e 100644 --- a/tests/unit/test_web3_clients.py +++ b/tests/unit/test_web3_clients.py @@ -16,13 +16,18 @@ """ import datetime +from unittest.mock import PropertyMock + import pytest from web3 import HTTPProvider, IPCProvider, WebsocketProvider from nucypher.blockchain.eth.clients import (EthereumClient, GanacheClient, GethClient, InfuraClient, PUBLIC_CHAINS, ParityClient) from nucypher.blockchain.eth.interfaces import BlockchainInterface +from tests.mock.interfaces import MockEthereumClient +DEFAULT_GAS_PRICE = 42 +GAS_PRICE_FROM_STRATEGY = 1234 # # Mock Providers @@ -330,3 +335,14 @@ def test_ganache_web3_client(): assert interface.client.platform is None assert interface.client.backend == 'ethereum-js' assert interface.client.is_local + + +def test_gas_prices(mocker, mock_ethereum_client): + web3_mock = mock_ethereum_client.w3 + + web3_mock.eth.generateGasPrice = mocker.Mock(side_effect=[None, GAS_PRICE_FROM_STRATEGY]) + type(web3_mock.eth).gasPrice = PropertyMock(return_value=DEFAULT_GAS_PRICE) # See docs of PropertyMock + + assert mock_ethereum_client.gas_price == DEFAULT_GAS_PRICE + assert mock_ethereum_client.gas_price_for_transaction("there's no gas strategy") == DEFAULT_GAS_PRICE + assert mock_ethereum_client.gas_price_for_transaction("2nd time is the charm") == GAS_PRICE_FROM_STRATEGY diff --git a/tests/utils/blockchain.py b/tests/utils/blockchain.py index 5f29e804f..2a71fb9d1 100644 --- a/tests/utils/blockchain.py +++ b/tests/utils/blockchain.py @@ -77,6 +77,7 @@ class TesterBlockchain(BlockchainDeployerInterface): GAS_STRATEGIES = {**BlockchainDeployerInterface.GAS_STRATEGIES, 'free': free_gas_price_strategy} + DEFAULT_GAS_STRATEGY = 'free' _PROVIDER_URI = 'tester://pyevm' _compiler = SolidityCompiler(source_dirs=[(SolidityCompiler.default_contract_dir(), {TEST_CONTRACTS_DIR})])