First pass at integrating gas price oracles

pull/2154/head
David Núñez 2020-07-23 17:06:51 +02:00
parent 1fbd2514fe
commit d6c986d9bb
2 changed files with 188 additions and 0 deletions

View File

@ -0,0 +1,97 @@
"""
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 abc import ABC, abstractmethod
from typing import Optional
import requests
from web3 import Web3
from web3.types import Wei
class Oracle(ABC):
class OracleError(RuntimeError):
"""Base class for Oracle-related exceptions"""
api_url = NotImplemented # TODO: Deal with API keys
def _probe_oracle(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)
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)
self._raw_data = response.json()
class EthereumGasPriceOracle(Oracle):
"""Base class for Ethereum gas price oracles"""
_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 oracle_based_gas_price_strategy(web3, transaction_params=None) -> Wei:
oracle = cls()
gas_price = oracle.get_gas_price()
return gas_price
return oracle_based_gas_price_strategy
class EtherchainGasPriceOracle(EthereumGasPriceOracle):
"""Gas price oracle from Etherchain"""
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.gas_prices = {k: int(Web3.toWei(v, 'gwei')) for k, v in self._raw_data.items()}
class UpvestGasPriceOracle(EthereumGasPriceOracle):
"""Gas price oracle from Upvest"""
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.gas_prices = {k: int(Web3.toWei(v, 'gwei')) for k, v in self._raw_data['estimates'].items()}
# TODO: We can implement here other oracles, 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,91 @@
"""
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 Oracle, EtherchainGasPriceOracle, UpvestGasPriceOracle
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():
etherchain_json = {
"safeLow": "99.0",
"standard": "105.0",
"fast": "108.0",
"fastest": "119.9"
}
oracle = EtherchainGasPriceOracle()
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
def test_upvest():
upvest_json = {
"success": True,
"updated": "2020-08-19T02:38:00.172Z",
"estimates": {
"fastest": 105.2745,
"fast": 97.158,
"medium": 91.424,
"slow": 87.19
}
}
oracle = UpvestGasPriceOracle()
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