diff --git a/gnosis/eth/oracles/__init__.py b/gnosis/eth/oracles/__init__.py index 81d7ac214..ee2b94310 100644 --- a/gnosis/eth/oracles/__init__.py +++ b/gnosis/eth/oracles/__init__.py @@ -1,16 +1,19 @@ # flake8: noqa F401 +from .cowswap import CowswapOracle +from .exceptions import ( + CannotGetPriceFromOracle, + InvalidPriceFromOracle, + OracleException, +) from .kyber import KyberOracle from .oracles import ( AaveOracle, BalancerOracle, - CannotGetPriceFromOracle, ComposedPriceOracle, CreamOracle, CurveOracle, EnzymeOracle, - InvalidPriceFromOracle, MooniswapOracle, - OracleException, PoolTogetherOracle, PriceOracle, PricePoolOracle, @@ -27,15 +30,13 @@ __all__ = [ "AaveOracle", "BalancerOracle", - "CannotGetPriceFromOracle", "ComposedPriceOracle", + "CowswapOracle", "CreamOracle", "CurveOracle", "EnzymeOracle", - "InvalidPriceFromOracle", "KyberOracle", "MooniswapOracle", - "OracleException", "PoolTogetherOracle", "PriceOracle", "PricePoolOracle", diff --git a/gnosis/eth/oracles/cowswap.py b/gnosis/eth/oracles/cowswap.py new file mode 100644 index 000000000..8c52a0ed4 --- /dev/null +++ b/gnosis/eth/oracles/cowswap.py @@ -0,0 +1,68 @@ +import logging +from typing import Optional + +from gnosis.protocol import GnosisProtocolAPI, OrderKind + +from .. import EthereumClient, EthereumNetworkNotSupported +from .exceptions import CannotGetPriceFromOracle +from .oracles import PriceOracle +from .utils import get_decimals + +logger = logging.getLogger(__name__) + + +class CowswapOracle(PriceOracle): + """ + CowSwap Oracle implementation + + https://docs.cow.fi/off-chain-services/api + """ + + def __init__( + self, + ethereum_client: EthereumClient, + ): + """ + :param ethereum_client: + """ + self.ethereum_client = ethereum_client + self.w3 = ethereum_client.w3 + self.api = GnosisProtocolAPI(ethereum_client.get_network()) + + @classmethod + def is_available( + cls, + ethereum_client: EthereumClient, + ) -> bool: + """ + :param ethereum_client: + :return: `True` if CowSwap is available for the EthereumClient provided, `False` otherwise + """ + try: + GnosisProtocolAPI(ethereum_client.get_network()) + return True + except EthereumNetworkNotSupported: + return False + + def get_price( + self, token_address_1: str, token_address_2: Optional[str] = None + ) -> float: + token_address_2 = token_address_2 or self.api.weth_address + if token_address_1 == token_address_2: + return 1.0 + + token_1_decimals = get_decimals(token_address_1, self.ethereum_client) + result = self.api.get_estimated_amount( + token_address_1, token_address_2, OrderKind.SELL, 10**token_1_decimals + ) + if "errorType" in result: + error_message = ( + f"Cannot get price from CowSwap {result} " + f"for token-1={token_address_1} to token-2={token_address_2}" + ) + logger.warning(error_message) + raise CannotGetPriceFromOracle(error_message) + else: + # Decimals needs to be adjusted + token_2_decimals = get_decimals(token_address_2, self.ethereum_client) + return float(result["amount"]) / 10**token_2_decimals diff --git a/gnosis/eth/oracles/exceptions.py b/gnosis/eth/oracles/exceptions.py new file mode 100644 index 000000000..60bb6c89c --- /dev/null +++ b/gnosis/eth/oracles/exceptions.py @@ -0,0 +1,10 @@ +class OracleException(Exception): + pass + + +class InvalidPriceFromOracle(OracleException): + pass + + +class CannotGetPriceFromOracle(OracleException): + pass diff --git a/gnosis/eth/oracles/kyber.py b/gnosis/eth/oracles/kyber.py index 3fa8b09da..db265fcef 100644 --- a/gnosis/eth/oracles/kyber.py +++ b/gnosis/eth/oracles/kyber.py @@ -8,12 +8,9 @@ from .. import EthereumClient, EthereumNetwork from ..contracts import get_kyber_network_proxy_contract -from .oracles import ( - CannotGetPriceFromOracle, - InvalidPriceFromOracle, - PriceOracle, - get_decimals, -) +from .exceptions import CannotGetPriceFromOracle, InvalidPriceFromOracle +from .oracles import PriceOracle +from .utils import get_decimals logger = logging.getLogger(__name__) diff --git a/gnosis/eth/oracles/oracles.py b/gnosis/eth/oracles/oracles.py index 881cb268a..00370d135 100644 --- a/gnosis/eth/oracles/oracles.py +++ b/gnosis/eth/oracles/oracles.py @@ -29,7 +29,9 @@ from .abis.cream_abis import cream_ctoken_abi from .abis.mooniswap_abis import mooniswap_abi from .abis.zerion_abis import ZERION_TOKEN_ADAPTER_ABI +from .exceptions import CannotGetPriceFromOracle, InvalidPriceFromOracle from .helpers.curve_gauge_list import CURVE_GAUGE_TO_LP_TOKEN +from .utils import get_decimals logger = logging.getLogger(__name__) @@ -40,18 +42,6 @@ class UnderlyingToken: quantity: int -class OracleException(Exception): - pass - - -class InvalidPriceFromOracle(OracleException): - pass - - -class CannotGetPriceFromOracle(OracleException): - pass - - class PriceOracle(ABC): @abstractmethod def get_price(self, *args) -> float: @@ -76,31 +66,6 @@ def get_underlying_tokens(self, *args) -> List[Tuple[UnderlyingToken]]: pass -@functools.lru_cache(maxsize=10_000) -def get_decimals( - token_address: ChecksumAddress, ethereum_client: EthereumClient -) -> int: - """ - Auxiliary function so RPC call to get `token decimals` is cached and - can be reused for every Oracle, instead of having a cache per Oracle. - - :param token_address: - :param ethereum_client: - :return: Decimals for a token - :raises CannotGetPriceFromOracle: If there's a problem with the query - """ - try: - return ethereum_client.erc20.get_decimals(token_address) - except ( - ValueError, - BadFunctionCallOutput, - DecodingError, - ) as e: - error_message = f"Cannot get decimals for token={token_address}" - logger.warning(error_message) - raise CannotGetPriceFromOracle(error_message) from e - - class UniswapOracle(PriceOracle): ADDRESSES = { EthereumNetwork.MAINNET: "0xc0a47dFe034B400B47bDaD5FecDa2621de6c4d95", diff --git a/gnosis/eth/oracles/uniswap_v3.py b/gnosis/eth/oracles/uniswap_v3.py index 90cbbd989..8281343ac 100644 --- a/gnosis/eth/oracles/uniswap_v3.py +++ b/gnosis/eth/oracles/uniswap_v3.py @@ -16,7 +16,9 @@ uniswap_v3_pool_abi, uniswap_v3_router_abi, ) -from .oracles import CannotGetPriceFromOracle, PriceOracle, get_decimals +from .exceptions import CannotGetPriceFromOracle +from .oracles import PriceOracle +from .utils import get_decimals logger = logging.getLogger(__name__) @@ -51,7 +53,7 @@ def is_available( """ :param ethereum_client: :param uniswap_v3_router_address: - :return: `True` is Uniswap V3 is available for the EthereumClient provided, `False` otherwise + :return: `True` if Uniswap V3 is available for the EthereumClient provided, `False` otherwise """ return ethereum_client.is_contract( uniswap_v3_router_address or cls.UNISWAP_V3_ROUTER diff --git a/gnosis/eth/oracles/utils.py b/gnosis/eth/oracles/utils.py new file mode 100644 index 000000000..6dac0b7a6 --- /dev/null +++ b/gnosis/eth/oracles/utils.py @@ -0,0 +1,37 @@ +import functools +import logging + +from eth_abi.exceptions import DecodingError +from eth_typing import ChecksumAddress +from web3.exceptions import BadFunctionCallOutput + +from gnosis.eth import EthereumClient + +from .exceptions import CannotGetPriceFromOracle + +logger = logging.getLogger(__name__) + + +@functools.lru_cache(maxsize=10_000) +def get_decimals( + token_address: ChecksumAddress, ethereum_client: EthereumClient +) -> int: + """ + Auxiliary function so RPC call to get `token decimals` is cached and + can be reused for every Oracle, instead of having a cache per Oracle. + + :param token_address: + :param ethereum_client: + :return: Decimals for a token + :raises CannotGetPriceFromOracle: If there's a problem with the query + """ + try: + return ethereum_client.erc20.get_decimals(token_address) + except ( + ValueError, + BadFunctionCallOutput, + DecodingError, + ) as e: + error_message = f"Cannot get decimals for token={token_address}" + logger.warning(error_message) + raise CannotGetPriceFromOracle(error_message) from e diff --git a/gnosis/eth/tests/oracles/test_cowswap.py b/gnosis/eth/tests/oracles/test_cowswap.py new file mode 100644 index 000000000..011aa68d6 --- /dev/null +++ b/gnosis/eth/tests/oracles/test_cowswap.py @@ -0,0 +1,73 @@ +from unittest import mock + +from django.test import TestCase + +from eth_account import Account + +from ... import EthereumClient +from ...oracles import CannotGetPriceFromOracle, CowswapOracle +from ..ethereum_test_case import EthereumTestCaseMixin +from ..test_oracles import ( + dai_token_mainnet_address, + usdc_token_mainnet_address, + usdt_token_mainnet_address, + weth_token_mainnet_address, +) +from ..utils import just_test_if_mainnet_node + + +class TestCowswapOracle(EthereumTestCaseMixin, TestCase): + def test_get_price(self): + mainnet_node = just_test_if_mainnet_node() + ethereum_client = EthereumClient(mainnet_node) + + self.assertTrue(CowswapOracle.is_available(ethereum_client)) + + cowswap_oracle = CowswapOracle(ethereum_client) + + price = cowswap_oracle.get_price(weth_token_mainnet_address) + self.assertEqual(price, 1.0) + + price = cowswap_oracle.get_price(dai_token_mainnet_address) + self.assertLess(price, 1) + self.assertGreater(price, 0) + + price = cowswap_oracle.get_price( + weth_token_mainnet_address, dai_token_mainnet_address + ) + self.assertGreater(price, 1) + + # Test with 2 stablecoins with same decimals + price = cowswap_oracle.get_price( + usdt_token_mainnet_address, usdc_token_mainnet_address + ) + self.assertAlmostEqual(price, 1.0, delta=0.5) + + # Test with 2 stablecoins with different decimals + price = cowswap_oracle.get_price( + dai_token_mainnet_address, usdc_token_mainnet_address + ) + self.assertAlmostEqual(price, 1.0, delta=0.5) + + price = cowswap_oracle.get_price( + usdc_token_mainnet_address, dai_token_mainnet_address + ) + self.assertAlmostEqual(price, 1.0, delta=0.5) + + random_token = Account.create().address + with self.assertRaisesMessage( + CannotGetPriceFromOracle, + f"Cannot get decimals for token={random_token}", + ): + cowswap_oracle.get_price(random_token) + + with mock.patch( + "gnosis.eth.oracles.cowswap.get_decimals", autospec=True, return_value=18 + ): + with self.assertRaisesMessage( + CannotGetPriceFromOracle, + f"Cannot get price from CowSwap " + f"{{'errorType': 'UnsupportedToken', 'description': 'Token address {random_token.lower()}'}} " + f"for token-1={random_token} to token-2={weth_token_mainnet_address}", + ): + cowswap_oracle.get_price(random_token) diff --git a/gnosis/eth/tests/oracles/test_sushiswap.py b/gnosis/eth/tests/oracles/test_sushiswap.py index 223fa3d47..66d98f9b6 100644 --- a/gnosis/eth/tests/oracles/test_sushiswap.py +++ b/gnosis/eth/tests/oracles/test_sushiswap.py @@ -2,7 +2,7 @@ from ... import EthereumClient from ...oracles import SushiswapOracle -from ...oracles.oracles import get_decimals as oracles_get_decimals +from ...oracles.utils import get_decimals as oracles_get_decimals from ..ethereum_test_case import EthereumTestCaseMixin from ..test_oracles import ( dai_token_mainnet_address, diff --git a/gnosis/eth/tests/test_oracles.py b/gnosis/eth/tests/test_oracles.py index 65e7df8ce..8a035279a 100644 --- a/gnosis/eth/tests/test_oracles.py +++ b/gnosis/eth/tests/test_oracles.py @@ -23,7 +23,7 @@ YearnOracle, ZerionComposedOracle, ) -from ..oracles.oracles import get_decimals as oracles_get_decimals +from ..oracles.utils import get_decimals as oracles_get_decimals from .ethereum_test_case import EthereumTestCaseMixin from .utils import just_test_if_mainnet_node diff --git a/gnosis/protocol/gnosis_protocol_api.py b/gnosis/protocol/gnosis_protocol_api.py index 87d55f5d5..b9a9fa4a8 100644 --- a/gnosis/protocol/gnosis_protocol_api.py +++ b/gnosis/protocol/gnosis_protocol_api.py @@ -9,6 +9,7 @@ from web3 import Web3 from gnosis.eth import EthereumNetwork, EthereumNetworkNotSupported +from gnosis.util import cached_property from .order import Order, OrderKind @@ -37,7 +38,7 @@ class AmountResponse(TypedDict): class ErrorResponse(TypedDict): - error_type: str + errorType: str description: str @@ -67,6 +68,18 @@ def __init__(self, ethereum_network: EthereumNetwork): self.domain_separator = self.build_domain_separator(self.network) self.base_url = self.api_base_urls[self.network] + @cached_property + def weth_address(self) -> ChecksumAddress: + """ + :return: Wrapped ether checksummed address + """ + if self.network == EthereumNetwork.MAINNET: + return ChecksumAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + elif self.network == EthereumNetwork.RINKEBY: + return ChecksumAddress("0xc778417E063141139Fce010982780140Aa0cD5Ab") + else: # XDAI + return ChecksumAddress("0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1") + @classmethod def build_domain_separator(cls, ethereum_network: EthereumNetwork): return make_domain( diff --git a/gnosis/protocol/tests/test_gnosis_protocol_api.py b/gnosis/protocol/tests/test_gnosis_protocol_api.py index 3a821fb8e..c7180f19b 100644 --- a/gnosis/protocol/tests/test_gnosis_protocol_api.py +++ b/gnosis/protocol/tests/test_gnosis_protocol_api.py @@ -16,7 +16,7 @@ def setUpClass(cls): super().setUpClass() cls.gnosis_protocol_api = GnosisProtocolAPI(EthereumNetwork.RINKEBY) cls.gno_token_address = "0x6810e776880C02933D47DB1b9fc05908e5386b96" - cls.rinkeby_weth_address = "0xc778417e063141139fce010982780140aa0cd5ab" + cls.weth_token_address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" cls.rinkeby_dai_address = "0x5592EC0cfb4dbc12D3aB100b257153436a1f0FEa" def test_api_is_available(self): @@ -30,7 +30,8 @@ def test_api_is_available(self): self.assertEqual(self.gnosis_protocol_api.get_orders(random_owner), []) def test_get_estimated_amount(self): - response = self.gnosis_protocol_api.get_estimated_amount( + gnosis_protocol_api = GnosisProtocolAPI(EthereumNetwork.MAINNET) + response = gnosis_protocol_api.get_estimated_amount( self.gno_token_address, self.gno_token_address, OrderKind.SELL, 1 ) self.assertDictEqual( @@ -41,7 +42,7 @@ def test_get_estimated_amount(self): }, ) - response = self.gnosis_protocol_api.get_estimated_amount( + response = gnosis_protocol_api.get_estimated_amount( "0x6820e776880c02933d47db1b9fc05908e5386b96", self.gno_token_address, OrderKind.SELL, @@ -50,6 +51,13 @@ def test_get_estimated_amount(self): self.assertIn("errorType", response) self.assertIn("description", response) + response = gnosis_protocol_api.get_estimated_amount( + self.gno_token_address, self.weth_token_address, OrderKind.SELL, int(1e18) + ) + amount = int(response["amount"]) / 1e18 + self.assertGreater(amount, 0) + self.assertLess(amount, 1) + def test_get_fee(self): order = Order( sellToken=self.gno_token_address, @@ -107,7 +115,7 @@ def test_place_order(self): result = self.gnosis_protocol_api.place_order(order, Account().create().key) self.assertEqual( order["feeAmount"], 0 - ) # Cannot estimate, as buy token is the same than sell token + ) # Cannot estimate, as buy token is the same as the sell token self.assertEqual( result, { @@ -116,7 +124,7 @@ def test_place_order(self): }, ) - order["sellToken"] = self.rinkeby_weth_address + order["sellToken"] = self.gnosis_protocol_api.weth_address order["buyToken"] = self.rinkeby_dai_address self.assertEqual( self.gnosis_protocol_api.place_order(order, Account().create().key),