Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Cowswap oracle #353

Merged
merged 2 commits into from
Sep 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions gnosis/eth/oracles/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -27,15 +30,13 @@
__all__ = [
"AaveOracle",
"BalancerOracle",
"CannotGetPriceFromOracle",
"ComposedPriceOracle",
"CowswapOracle",
"CreamOracle",
"CurveOracle",
"EnzymeOracle",
"InvalidPriceFromOracle",
"KyberOracle",
"MooniswapOracle",
"OracleException",
"PoolTogetherOracle",
"PriceOracle",
"PricePoolOracle",
Expand Down
74 changes: 74 additions & 0 deletions gnosis/eth/oracles/cowswap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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)
try:
result = self.api.get_estimated_amount(
token_address_1, token_address_2, OrderKind.SELL, 10**token_1_decimals
)
if "amount" in result:
# Decimals needs to be adjusted
token_2_decimals = get_decimals(token_address_2, self.ethereum_client)
return float(result["amount"]) / 10**token_2_decimals
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use fractions here since we are dealing with financial decimal values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea, but I wouldn't do it as it would break oracle interface for all the implemented oracles. At some point we can refactor all the oracles to return Fraction


exception = None
except IOError as exc:
exception = exc
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) from exception
10 changes: 10 additions & 0 deletions gnosis/eth/oracles/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class OracleException(Exception):
pass


class InvalidPriceFromOracle(OracleException):
pass


class CannotGetPriceFromOracle(OracleException):
pass
9 changes: 3 additions & 6 deletions gnosis/eth/oracles/kyber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
39 changes: 2 additions & 37 deletions gnosis/eth/oracles/oracles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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:
Expand All @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions gnosis/eth/oracles/uniswap_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions gnosis/eth/oracles/utils.py
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions gnosis/eth/tests/oracles/test_cowswap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from unittest import mock

from django.test import TestCase

from eth_account import Account
from requests import Session

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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOC: is this testing the real node (E2E)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, we are doing E2E for all oracle providers

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)

with mock.patch.object(Session, "get", side_effect=IOError("Connection Error")):
with self.assertRaisesMessage(
CannotGetPriceFromOracle,
f"Cannot get price from CowSwap "
f"{{}} "
f"for token-1={usdc_token_mainnet_address} to token-2={dai_token_mainnet_address}",
):
cowswap_oracle.get_price(
usdc_token_mainnet_address, dai_token_mainnet_address
)

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)
2 changes: 1 addition & 1 deletion gnosis/eth/tests/oracles/test_sushiswap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion gnosis/eth/tests/test_oracles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading