Renaming Oracles to Datafeeds

pull/2154/head
David Núñez 2020-08-28 17:51:54 +02:00
parent 7d5646e2f5
commit 066fd8461d
4 changed files with 178 additions and 175 deletions

View File

@ -57,7 +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.oracles import oracle_fallback_gas_price_strategy
from nucypher.utilities.datafeeds import datafeed_fallback_gas_price_strategy
Web3Providers = Union[IPCProvider, WebsocketProvider, HTTPProvider, EthereumTester]
@ -281,7 +281,7 @@ class BlockchainInterface:
# 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 = oracle_fallback_gas_price_strategy
gas_strategy = datafeed_fallback_gas_price_strategy
else:
gas_strategy = self.gas_strategy
self.client.set_gas_strategy(gas_strategy=gas_strategy)

View File

@ -23,24 +23,24 @@ from web3.gas_strategies.rpc import rpc_gas_price_strategy
from web3.types import Wei, TxParams
class Oracle(ABC):
class Datafeed(ABC):
class OracleError(RuntimeError):
"""Base class for Oracle-related exceptions"""
class DatafeedError(RuntimeError):
"""Base class for exceptions concerning Datafeeds"""
name = NotImplemented
api_url = NotImplemented # TODO: Deal with API keys
def _probe_oracle(self):
def _probe_feed(self):
try:
response = requests.get(self.api_url)
except requests.exceptions.ConnectionError as e:
error = f"Failed to probe oracle at {self.api_url}: {str(e)}"
raise self.OracleError(error)
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 oracle at {self.api_url} with status code {response.status_code}"
raise self.OracleError(error)
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()
@ -48,8 +48,8 @@ class Oracle(ABC):
return f"{self.name} ({self.api_url})"
class EthereumGasPriceOracle(Oracle):
"""Base class for Ethereum gas price oracles"""
class EthereumGasPriceDatafeed(Datafeed):
"""Base class for Ethereum gas price data feeds"""
_speed_names = NotImplemented
_default_speed = NotImplemented
@ -66,47 +66,47 @@ class EthereumGasPriceOracle(Oracle):
@classmethod
def construct_gas_strategy(cls):
def oracle_based_gas_price_strategy(web3: Web3, transaction_params: TxParams = None) -> Wei:
oracle = cls()
gas_price = oracle.get_gas_price()
def gas_price_strategy(web3: Web3, transaction_params: TxParams = None) -> Wei:
feed = cls()
gas_price = feed.get_gas_price()
return gas_price
return oracle_based_gas_price_strategy
return gas_price_strategy
class EtherchainGasPriceOracle(EthereumGasPriceOracle):
"""Gas price oracle from Etherchain"""
class EtherchainGasPriceDatafeed(EthereumGasPriceDatafeed):
"""Gas price datafeed from Etherchain"""
name = "Etherchain oracle"
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_oracle()
self._probe_feed()
self.gas_prices = {k: int(Web3.toWei(v, 'gwei')) for k, v in self._raw_data.items()}
class UpvestGasPriceOracle(EthereumGasPriceOracle):
"""Gas price oracle from Upvest"""
class UpvestGasPriceDatafeed(EthereumGasPriceDatafeed):
"""Gas price datafeed from Upvest"""
name = "Upvest oracle"
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_oracle()
self._probe_feed()
self.gas_prices = {k: int(Web3.toWei(v, 'gwei')) for k, v in self._raw_data['estimates'].items()}
def oracle_fallback_gas_price_strategy(web3: Web3, transaction_params: TxParams = None) -> Wei:
oracles = (EtherchainGasPriceOracle, UpvestGasPriceOracle)
def datafeed_fallback_gas_price_strategy(web3: Web3, transaction_params: TxParams = None) -> Wei:
feeds = (EtherchainGasPriceDatafeed, UpvestGasPriceDatafeed)
for gas_price_oracle_class in oracles:
for gas_price_feed_class in feeds:
try:
gas_strategy = gas_price_oracle_class.construct_gas_strategy()
gas_strategy = gas_price_feed_class.construct_gas_strategy()
gas_price = gas_strategy(web3, transaction_params)
except Oracle.OracleError:
except Datafeed.DatafeedError:
continue
else:
return gas_price
@ -116,5 +116,5 @@ def oracle_fallback_gas_price_strategy(web3: Web3, transaction_params: TxParams
# TODO: We can implement here other oracles, like the ETH/USD (e.g., https://api.coinmarketcap.com/v1/ticker/ethereum/)
# 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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -1,145 +0,0 @@
"""
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 <https://www.gnu.org/licenses/>.
"""
from unittest.mock import patch
import pytest
from requests.exceptions import ConnectionError
from web3 import Web3
from nucypher.utilities.oracles import (
EtherchainGasPriceOracle,
Oracle,
oracle_fallback_gas_price_strategy,
UpvestGasPriceOracle
)
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_oracle(mocker):
oracle = Oracle()
oracle.api_url = "http://foo.bar"
with patch('requests.get', side_effect=ConnectionError("Bad connection")) as mocked_get:
with pytest.raises(Oracle.OracleError, match="Bad connection"):
oracle._probe_oracle()
mocked_get.assert_called_once_with(oracle.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(Oracle.OracleError, match="status code 400"):
oracle._probe_oracle()
mocked_get.assert_called_once_with(oracle.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
oracle._probe_oracle()
mocked_get.assert_called_once_with(oracle.api_url)
assert oracle._raw_data == json
def test_etherchain():
oracle = EtherchainGasPriceOracle()
assert set(oracle._speed_names).issubset(etherchain_json.keys())
assert oracle._default_speed in etherchain_json.keys()
with patch.object(oracle, '_probe_oracle'):
oracle._raw_data = etherchain_json
assert oracle.get_gas_price('safeLow') == Web3.toWei(99.0, 'gwei')
assert oracle.get_gas_price('standard') == Web3.toWei(105.0, 'gwei')
assert oracle.get_gas_price('fast') == Web3.toWei(108.0, 'gwei')
assert oracle.get_gas_price('fastest') == Web3.toWei(119.9, 'gwei')
assert oracle.get_gas_price() == oracle.get_gas_price('fast') # Default
parsed_gas_prices = oracle.gas_prices
with patch('nucypher.utilities.oracles.EtherchainGasPriceOracle._parse_gas_prices', autospec=True):
EtherchainGasPriceOracle.gas_prices = dict()
with patch.dict(EtherchainGasPriceOracle.gas_prices, values=parsed_gas_prices):
gas_strategy = oracle.construct_gas_strategy()
assert gas_strategy("web3", "tx") == Web3.toWei(108.0, 'gwei')
def test_upvest():
oracle = UpvestGasPriceOracle()
assert set(oracle._speed_names).issubset(upvest_json['estimates'].keys())
assert oracle._default_speed in upvest_json['estimates'].keys()
with patch.object(oracle, '_probe_oracle'):
oracle._raw_data = upvest_json
assert oracle.get_gas_price('slow') == Web3.toWei(87.19, 'gwei')
assert oracle.get_gas_price('medium') == Web3.toWei(91.424, 'gwei')
assert oracle.get_gas_price('fast') == Web3.toWei(97.158, 'gwei')
assert oracle.get_gas_price('fastest') == Web3.toWei(105.2745, 'gwei')
assert oracle.get_gas_price() == oracle.get_gas_price('fastest') # Default
parsed_gas_prices = oracle.gas_prices
with patch('nucypher.utilities.oracles.UpvestGasPriceOracle._parse_gas_prices', autospec=True):
UpvestGasPriceOracle.gas_prices = dict()
with patch.dict(UpvestGasPriceOracle.gas_prices, values=parsed_gas_prices):
gas_strategy = oracle.construct_gas_strategy()
assert gas_strategy("web3", "tx") == Web3.toWei(105.2745, 'gwei')
def test_oracle_fallback_gas_price_strategy():
mocked_gas_price = 0xFABADA
def mock_gas_strategy(web3, tx=None):
return mocked_gas_price
# In normal circumstances, the first oracle (Etherchain) will return the gas price
with patch('nucypher.utilities.oracles.EtherchainGasPriceOracle.construct_gas_strategy',
return_value=mock_gas_strategy):
assert oracle_fallback_gas_price_strategy("web3", "tx") == mocked_gas_price
# If the first oracle in the chain fails, we resort to the second one
with patch('nucypher.utilities.oracles.EtherchainGasPriceOracle._probe_oracle', side_effect=Oracle.OracleError):
with patch('nucypher.utilities.oracles.UpvestGasPriceOracle.construct_gas_strategy',
return_value=mock_gas_strategy):
assert oracle_fallback_gas_price_strategy("web3", "tx") == mocked_gas_price
# If both oracles fail, we fallback to the rpc_gas_price_strategy
with patch('nucypher.utilities.oracles.EtherchainGasPriceOracle._probe_oracle', side_effect=Oracle.OracleError):
with patch('nucypher.utilities.oracles.UpvestGasPriceOracle._probe_oracle', side_effect=Oracle.OracleError):
with patch('nucypher.utilities.oracles.rpc_gas_price_strategy', side_effect=mock_gas_strategy):
assert oracle_fallback_gas_price_strategy("web3", "tx") == mocked_gas_price