from collections.abc import Callable from statistics import median from unittest.mock import patch import pytest from constant_sorrow.constants import FAST, FASTEST, MEDIUM, SLOW from requests.exceptions import ConnectionError from web3 import Web3 from nucypher.utilities.datafeeds import ( Datafeed, EtherchainGasPriceDatafeed, EthereumGasPriceDatafeed, UpvestGasPriceDatafeed, ZoltuGasPriceDatafeed, ) from nucypher.utilities.gas_strategies import construct_datafeed_median_strategy 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 } } zoltu_json = { "number_of_blocks": 200, "latest_block_number": 11294588, "percentile_1": "1 nanoeth", "percentile_2": "1 nanoeth", "percentile_3": "3.452271231 nanoeth", "percentile_4": "10 nanoeth", "percentile_5": "10 nanoeth", "percentile_10": "18.15 nanoeth", "percentile_15": "25 nanoeth", "percentile_20": "28 nanoeth", "percentile_25": "30 nanoeth", "percentile_30": "32 nanoeth", "percentile_35": "37 nanoeth", "percentile_40": "41 nanoeth", "percentile_45": "44 nanoeth", "percentile_50": "47.000001459 nanoeth", "percentile_55": "50 nanoeth", "percentile_60": "52.5 nanoeth", "percentile_65": "55.20000175 nanoeth", "percentile_70": "56.1 nanoeth", "percentile_75": "58 nanoeth", "percentile_80": "60.20000175 nanoeth", "percentile_85": "63 nanoeth", "percentile_90": "64 nanoeth", "percentile_95": "67 nanoeth", "percentile_96": "67.32 nanoeth", "percentile_97": "68 nanoeth", "percentile_98": "70 nanoeth", "percentile_99": "71 nanoeth", "percentile_100": "74 nanoeth" } 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_canonical_speed_names(): # Valid speed names, grouped in equivalence classes speed_equivalence_classes = { SLOW: ('slow', 'SLOW', 'Slow', 'safeLow', 'safelow', 'SafeLow', 'SAFELOW', 'low', 'LOW', 'Low'), MEDIUM: ('medium', 'MEDIUM', 'Medium', 'standard', 'STANDARD', 'Standard', 'average', 'AVERAGE', 'Average'), FAST: ('fast', 'FAST', 'Fast', 'high', 'HIGH', 'High'), FASTEST: ('fastest', 'FASTEST', 'Fastest') } for canonical_speed, equivalence_class in speed_equivalence_classes.items(): for speed_name in equivalence_class: assert canonical_speed == EthereumGasPriceDatafeed.get_canonical_speed(speed_name) # Invalid speed names, but that are somewhat similar to a valid one so we can give a suggestion similarities = ( ('hihg', 'high'), ('zlow', 'low'), ) for wrong_name, suggestion in similarities: message = f"'{wrong_name}' is not a valid speed name. Did you mean '{suggestion}'?" with pytest.raises(LookupError, match=message): EthereumGasPriceDatafeed.get_canonical_speed(wrong_name) # Utterly wrong speed names. Shame on you. wrong_name = "🙈" with pytest.raises(LookupError, match=f"'{wrong_name}' is not a valid speed name."): EthereumGasPriceDatafeed.get_canonical_speed(wrong_name) def test_etherchain(): feed = EtherchainGasPriceDatafeed() assert set(feed._speed_names.values()).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.to_wei(99.0, 'gwei') assert feed.get_gas_price('standard') == Web3.to_wei(105.0, 'gwei') assert feed.get_gas_price('fast') == Web3.to_wei(108.0, 'gwei') assert feed.get_gas_price('fastest') == Web3.to_wei(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.to_wei(108.0, 'gwei') def test_upvest(): feed = UpvestGasPriceDatafeed() assert set(feed._speed_names.values()).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.to_wei(87.19, 'gwei') assert feed.get_gas_price('medium') == Web3.to_wei(91.424, 'gwei') assert feed.get_gas_price('fast') == Web3.to_wei(97.158, 'gwei') assert feed.get_gas_price('fastest') == Web3.to_wei(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.to_wei(105.2745, 'gwei') def test_zoltu(): feed = ZoltuGasPriceDatafeed() assert set(feed._speed_names.values()).issubset(zoltu_json.keys()) # assert feed._default_speed in zoltu_json.keys() with patch.object(feed, '_probe_feed'): feed._raw_data = zoltu_json assert feed.get_gas_price('slow') == Web3.to_wei(41, 'gwei') assert feed.get_gas_price('medium') == Web3.to_wei(58, 'gwei') assert feed.get_gas_price('fast') == Web3.to_wei(67, 'gwei') assert feed.get_gas_price('fastest') == Web3.to_wei(70, 'gwei') assert feed.get_gas_price() == feed.get_gas_price('fast') # Default parsed_gas_prices = feed.gas_prices with patch('nucypher.utilities.datafeeds.ZoltuGasPriceDatafeed._parse_gas_prices', autospec=True): ZoltuGasPriceDatafeed.gas_prices = dict() with patch.dict(ZoltuGasPriceDatafeed.gas_prices, values=parsed_gas_prices): gas_strategy = feed.construct_gas_strategy() assert gas_strategy("web3", "tx") == Web3.to_wei(67, 'gwei') def test_datafeed_median_gas_price_strategy(): mock_upvest_gas_price = 2000 mock_zoltu_gas_price = 4000 mock_rpc_gas_price = 42 def construct_mock_gas_strategy(gas_price) -> Callable: def _mock_gas_strategy(web3, tx=None): return gas_price return _mock_gas_strategy # In normal circumstances, all datafeeds in the strategy work, and the median is returned with patch('nucypher.utilities.datafeeds.UpvestGasPriceDatafeed.construct_gas_strategy', return_value=construct_mock_gas_strategy(mock_upvest_gas_price)): # with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed.construct_gas_strategy', # return_value=construct_mock_gas_strategy(mock_etherchain_gas_price)): with patch('nucypher.utilities.datafeeds.ZoltuGasPriceDatafeed.construct_gas_strategy', return_value=construct_mock_gas_strategy(mock_zoltu_gas_price)): datafeed_median_gas_price_strategy = construct_datafeed_median_strategy() assert datafeed_median_gas_price_strategy("web3", "tx") == median([mock_upvest_gas_price, mock_zoltu_gas_price]) # If, for example, Upvest fails, the median is computed using the other two feeds with patch('nucypher.utilities.datafeeds.UpvestGasPriceDatafeed._probe_feed', side_effect=Datafeed.DatafeedError): # with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed.construct_gas_strategy', # return_value=construct_mock_gas_strategy(mock_etherchain_gas_price)): with patch('nucypher.utilities.datafeeds.ZoltuGasPriceDatafeed.construct_gas_strategy', return_value=construct_mock_gas_strategy(mock_zoltu_gas_price)): datafeed_median_gas_price_strategy = construct_datafeed_median_strategy() assert datafeed_median_gas_price_strategy("web3", "tx") == median([mock_zoltu_gas_price]) # If only one feed works, then the return value corresponds to this feed with patch('nucypher.utilities.datafeeds.UpvestGasPriceDatafeed._probe_feed', side_effect=Datafeed.DatafeedError): # with patch('nucypher.utilities.datafeeds.EtherchainGasPriceDatafeed._probe_feed', # side_effect=Datafeed.DatafeedError): with patch('nucypher.utilities.datafeeds.ZoltuGasPriceDatafeed.construct_gas_strategy', return_value=construct_mock_gas_strategy(mock_zoltu_gas_price)): datafeed_median_gas_price_strategy = construct_datafeed_median_strategy() assert datafeed_median_gas_price_strategy("web3", "tx") == mock_zoltu_gas_price # If all feeds 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.ZoltuGasPriceDatafeed._probe_feed', side_effect=Datafeed.DatafeedError): with patch('nucypher.utilities.gas_strategies.rpc_gas_price_strategy', side_effect=construct_mock_gas_strategy(mock_rpc_gas_price)): datafeed_median_gas_price_strategy = construct_datafeed_median_strategy() assert datafeed_median_gas_price_strategy("web3", "tx") == mock_rpc_gas_price