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})])