diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..16b3944 --- /dev/null +++ b/.env.sample @@ -0,0 +1,7 @@ +# Test project outside docker +PYTHONPATH=/app/ +DEBUG=0 +DJANGO_SETTINGS_MODULE=config.settings.test +DJANGO_SECRET_KEY=t3st-s3cr3t#-!k3y +ETHEREUM_NODES_URLS=https://ethereum.publicnode.com,https://polygon-rpc.com +ETH_HASH_BACKEND=pysha3 diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..b2fcc2d --- /dev/null +++ b/.env.test @@ -0,0 +1,8 @@ +# Test project outside docker +PYTHONPATH=/app/ +DEBUG=0 +DJANGO_SETTINGS_MODULE=config.settings.test +DJANGO_SECRET_KEY=t3st-s3cr3t#-!k3y +ETHEREUM_MAINNET_NODE=https://ethereum.publicnode.com +ETHEREUM_NODES_URLS=http://localhost:8545 +ETH_HASH_BACKEND=pysha3 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3a09ebc..aee684d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,7 +2,7 @@ name: Python CI on: push: branches: - - master + - main - develop pull_request: release: @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ["3.11"] steps: - uses: actions/checkout@v4 @@ -30,38 +30,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] - services: - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - postgres: - image: postgres:14 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - rabbitmq: - image: rabbitmq:alpine - options: >- - --health-cmd "rabbitmqctl await_startup" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - "5672:5672" + python-version: ["3.11"] steps: - name: Setup and run ganache run: | @@ -82,20 +51,14 @@ jobs: - name: Run tests and coverage run: | python manage.py check - python manage.py makemigrations --check --dry-run + # python manage.py makemigrations --check --dry-run coverage run --source=$SOURCE_FOLDER -m pytest -rxXs --reruns 3 env: SOURCE_FOLDER: safe_price_service - CELERY_BROKER_URL: redis://localhost:6379/0 - COINMARKETCAP_API_TOKEN: ${{ secrets.COINMARKETCAP_API_TOKEN }} - DATABASE_URL: psql://postgres:postgres@localhost/postgres DJANGO_SETTINGS_MODULE: config.settings.test ETHEREUM_MAINNET_NODE: ${{ secrets.ETHEREUM_MAINNET_NODE }} - ETHEREUM_NODE_URL: http://localhost:8545 - ETHEREUM_TRACING_NODE_URL: http://localhost:8545 + ETHEREUM_NODES_URLS: http://localhost:8545 ETH_HASH_BACKEND: pysha3 - REDIS_URL: redis://localhost:6379/0 - EVENTS_QUEUE_URL: amqp://guest:guest@localhost:5672/ - name: Coveralls uses: coverallsapp/github-action@v2 docker-deploy: @@ -103,7 +66,7 @@ jobs: needs: - linting - test-app - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' || (github.event_name == 'release' && github.event.action == 'released') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || (github.event_name == 'release' && github.event.action == 'released') steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 @@ -115,8 +78,8 @@ jobs: with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Deploy Master - if: github.ref == 'refs/heads/master' + - name: Deploy main + if: github.ref == 'refs/heads/main' uses: docker/build-push-action@v5 with: context: . @@ -159,11 +122,11 @@ jobs: autodeploy: runs-on: ubuntu-latest needs: [docker-deploy] - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' steps: - uses: actions/checkout@v4 - name: Deploy Staging - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/main' run: bash scripts/autodeploy.sh env: AUTODEPLOY_URL: ${{ secrets.AUTODEPLOY_URL }} diff --git a/.gitignore b/.gitignore index 4716264..147e939 100644 --- a/.gitignore +++ b/.gitignore @@ -105,7 +105,7 @@ venv.bak/ # Django staticfiles/ -safe_transaction_service/media +safe_price_service/media ### VisualStudioCode template .vscode/* @@ -117,7 +117,7 @@ safe_transaction_service/media # Provided default Pycharm Run/Debug Configurations should be tracked by git # In case of local modifications made by Pycharm, use update-index command # for each changed file, like this: -# git update-index --assume-unchanged .idea/safe_transaction_service.iml +# git update-index --assume-unchanged .idea/safe_price_service.iml ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 324e9b7..4a96652 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,15 +6,15 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.11.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-docstring-first - id: check-merge-conflict diff --git a/README.md b/README.md index fac01e4..560c695 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,18 @@ ![Build Status](https://github.com/safe-global/safe-price-service/workflows/Python%20CI/badge.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/safe-global/safe-price-service/badge.svg?branch=master)](https://coveralls.io/github/safe-global/safe-price-service?branch=master) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) -![Python 3.10](https://img.shields.io/badge/Python-3.10-blue.svg) +![Python 3.11](https://img.shields.io/badge/Python-3.11-blue.svg) ![Django 4](https://img.shields.io/badge/Django-4-blue.svg) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/safeglobal/safe-price-service?label=Docker&sort=semver)](https://hub.docker.com/r/safeglobal/safe-price-service) # Safe Price Service Returns fiat prices for base currencies and ERC20 tokens. +## Configuration + +Environment variables: +- `ETHEREUM_NODES_URLS`: Comma separated list of the node RPCS for the chains supported for fetching prices. +- `PRICES_CACHE_TTL_MINUTES`: Minutes to keep a price in cache. + ## Contributors [See contributors](https://github.com/safe-global/safe-price-service/graphs/contributors) diff --git a/config/__init__.py b/config/__init__.py index 10f5014..e69de29 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,5 +0,0 @@ -# This will make sure the app is always imported when -# Django starts so that shared_task will use this app. -from .celery_app import app as celery_app - -__all__ = ("celery_app",) diff --git a/config/settings/base.py b/config/settings/base.py index 7c4bfe4..63d8aff 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -40,16 +40,11 @@ SITE_ID = 1 # https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n USE_I18N = True -# https://docs.djangoproject.com/en/dev/ref/settings/#use-l10n -USE_L10N = True # https://docs.djangoproject.com/en/dev/ref/settings/#use-tz USE_TZ = True # https://docs.djangoproject.com/en/3.2/ref/settings/#force-script-name FORCE_SCRIPT_NAME = env("FORCE_SCRIPT_NAME", default=None) -# Enable analytics endpoints -ENABLE_ANALYTICS = env("ENABLE_ANALYTICS", default=False) - # GUNICORN GUNICORN_REQUEST_TIMEOUT = gunicorn_request_timeout GUNICORN_WORKER_CONNECTIONS = gunicorn_worker_connections @@ -58,23 +53,14 @@ # DATABASES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases -DB_STATEMENT_TIMEOUT = env.int("DB_STATEMENT_TIMEOUT", 60_000) +# Project does not need a database for now DATABASES = { - "default": env.db("DATABASE_URL"), -} -DATABASES["default"]["ATOMIC_REQUESTS"] = False -DATABASES["default"]["ENGINE"] = "django_db_geventpool.backends.postgresql_psycopg2" -DATABASES["default"]["CONN_MAX_AGE"] = 0 -DB_MAX_CONNS = env.int("DB_MAX_CONNS", default=50) -DATABASES["default"]["OPTIONS"] = { - # https://github.com/jneight/django-db-geventpool#settings - "MAX_CONNS": DB_MAX_CONNS, - "REUSE_CONNS": env.int("DB_REUSE_CONNS", default=DB_MAX_CONNS), - "options": f"-c statement_timeout={DB_STATEMENT_TIMEOUT}", + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } } -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - # URLS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf @@ -297,10 +283,16 @@ }, } -REDIS_URL = env("REDIS_URL", default="redis://localhost:6379/0") - SWAGGER_SETTINGS = { "SECURITY_DEFINITIONS": { "api_key": {"type": "apiKey", "in": "header", "name": "Authorization"} }, } + +# Ethereum +ETHEREUM_NODE_URL = env("ETHEREUM_NODE_URL", default=None) +ETHEREUM_NODES_URLS = env.list("ETHEREUM_NODES_URLS", default=[]) + + +# Prices +PRICES_CACHE_TTL_MINUTES = env.int("PRICES_CACHE_TTL_MINUTES", default=60) diff --git a/config/settings/local.py b/config/settings/local.py index 5712329..c106570 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -13,21 +13,12 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) -REDIS_URL = env.str("REDIS_URL") - # CACHES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - # Mimicing memcache behavior. - # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior - "IGNORE_EXCEPTIONS": True, - }, + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", } } diff --git a/config/settings/production.py b/config/settings/production.py index 35a0a01..699f42c 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -13,23 +13,6 @@ # DATABASES['default'] = env.db('DATABASE_URL') # noqa F405 DATABASES["default"]["ATOMIC_REQUESTS"] = False # noqa F405 -REDIS_URL = env.str("REDIS_URL") - -# CACHES -# ------------------------------------------------------------------------------ -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - # Mimicing memcache behavior. - # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior - "IGNORE_EXCEPTIONS": True, - }, - } -} - # SECURITY # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff diff --git a/config/settings/test.py b/config/settings/test.py index 71c7d7b..87c075b 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -31,10 +31,12 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] -# Fix error with `task_id` when running celery in eager mode -LOGGING["formatters"]["celery_verbose"] = LOGGING["formatters"]["verbose"] # noqa F405 LOGGING["loggers"] = { # noqa F405 "safe_price_service": { "level": "DEBUG", } } + +ETHEREUM_TEST_PRIVATE_KEY = ( + "6370fd033278c143179d81c5526140625662b8daa446c22ee2d73db3707e620c" +) diff --git a/docker-compose.yml b/docker-compose.yml index 8fec0fe..dd7964c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,21 +15,6 @@ services: depends_on: - web - redis: - image: redis:alpine - ports: - - "6379:6379" - command: - - --appendonly yes - - db: - image: postgres:14-alpine - ports: - - "5432:5432" - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - web: build: context: . diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 05cd544..acf10a7 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim +FROM python:3.11-slim ARG APP_HOME=/app WORKDIR ${APP_HOME} @@ -27,5 +27,15 @@ RUN set -ex \ -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \ -exec rm -rf '{}' + -COPY . . +# /nginx mount point must be created before so it doesn't have root permissions +# ${APP_HOME} root folder will not be updated by COPY --chown, so permissions need to be adjusted +RUN groupadd -g 999 python && \ + useradd -u 999 -r -g python python && \ + mkdir -p /nginx && \ + chown -R python:python /nginx ${APP_HOME} +COPY --chown=python:python . . + +# Use numeric ids so kubernetes identifies the user correctly +USER 999:999 + RUN DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_DOT_ENV_FILE=.env.tracing.sample python manage.py collectstatic --noinput diff --git a/docker/web/Dockerfile_alpine b/docker/web/Dockerfile_alpine deleted file mode 100644 index dd9c405..0000000 --- a/docker/web/Dockerfile_alpine +++ /dev/null @@ -1,22 +0,0 @@ -# Less size than Debian, slowest to build -FROM python:3.10-alpine - -ENV PYTHONUNBUFFERED 1 -WORKDIR /app - -COPY requirements.txt ./ - -# Signal handling for PID1 https://github.com/krallin/tini -RUN apk add --update --no-cache tini libpq && \ - apk add --no-cache --virtual .build-dependencies postgresql-dev alpine-sdk libffi-dev && \ - pip install --no-cache-dir -r requirements.txt && \ - apk del .build-dependencies && \ - find /usr/local \ - \( -type d -a -name test -o -name tests \) \ - -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \ - -exec rm -rf '{}' + - -COPY . . -RUN DJANGO_SETTINGS_MODULE=config.settings.local DJANGO_DOT_ENV_FILE=.env.local python manage.py collectstatic --noinput - -ENTRYPOINT ["/sbin/tini", "--"] diff --git a/requirements-test.txt b/requirements-test.txt index 71ada5f..36d5946 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -6,7 +6,6 @@ factory-boy==3.3.0 faker==19.11.0 mypy==1.6.1 pytest==7.4.2 -pytest-celery==0.0.0 pytest-django==4.5.2 pytest-env==1.0.1 pytest-rerunfailures==12.0 diff --git a/requirements.txt b/requirements.txt index fadd85f..22f84eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,36 +1,20 @@ -boto3==1.28.62 cachetools==5.3.1 -celery==5.3.4 django==4.2.6 -django-cache-memoize==0.2.0 -django-celery-beat==2.5.0 django-cors-headers==4.3.0 django-db-geventpool==4.0.1 django-debug-toolbar django-debug-toolbar-force django-environ==0.11.2 django-extensions==3.2.3 -django-filter==23.3 -django-imagekit==5.0.0 -django-model-utils==4.3.1 -django-redis==5.4.0 -django-s3-storage==0.14.0 -django-timezone-field==6.0.1 djangorestframework==3.14.0 djangorestframework-camel-case==1.4.2 docutils==0.20.1 drf-yasg[validation]==1.21.7 -firebase-admin==6.2.0 -flower==2.0.1 gunicorn[gevent]==21.2.0 hexbytes==0.3.1 -hiredis==2.2.3 packaging>=21.0 -pika==1.3.2 -pillow==10.1.0 psycogreen==1.0.2 psycopg2==2.9.9 -redis==5.0.1 requests==2.31.0 -safe-eth-py[django]==6.0.0b4 +safe-eth-py[django]==6.0.0b7 web3==6.11.1 diff --git a/safe_price_service/tokens/__init__.py b/safe_price_service/tokens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/tokens/apps.py b/safe_price_service/tokens/apps.py new file mode 100644 index 0000000..46f3d39 --- /dev/null +++ b/safe_price_service/tokens/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TokensConfig(AppConfig): + name = "safe_price_service.tokens" + verbose_name = "Tokens for Safe Transaction Service" diff --git a/safe_price_service/tokens/clients/__init__.py b/safe_price_service/tokens/clients/__init__.py new file mode 100644 index 0000000..137e017 --- /dev/null +++ b/safe_price_service/tokens/clients/__init__.py @@ -0,0 +1,7 @@ +# flake8: noqa F401 +from .binance_client import BinanceClient +from .coingecko_client import CoingeckoClient +from .exceptions import CannotGetPrice +from .kleros_client import KlerosClient, KlerosToken +from .kraken_client import KrakenClient +from .kucoin_client import KucoinClient diff --git a/safe_price_service/tokens/clients/base_client.py b/safe_price_service/tokens/clients/base_client.py new file mode 100644 index 0000000..08c1afe --- /dev/null +++ b/safe_price_service/tokens/clients/base_client.py @@ -0,0 +1,24 @@ +import requests + + +class BaseHTTPClient: + def __init__(self, request_timeout: int = 10): + self.http_session = self._prepare_http_session() + self.request_timeout = request_timeout + + def _prepare_http_session(self) -> requests.Session: + """ + Prepare http session with custom pooling. See: + https://urllib3.readthedocs.io/en/stable/advanced-usage.html + https://docs.python-requests.org/en/v1.2.3/api/#requests.adapters.HTTPAdapter + https://web3py.readthedocs.io/en/stable/providers.html#httpprovider + """ + session = requests.Session() + adapter = requests.adapters.HTTPAdapter( + pool_connections=10, + pool_maxsize=100, # Number of concurrent connections + pool_block=False, + ) + session.mount("http://", adapter) + session.mount("https://", adapter) + return session diff --git a/safe_price_service/tokens/clients/binance_client.py b/safe_price_service/tokens/clients/binance_client.py new file mode 100644 index 0000000..7581036 --- /dev/null +++ b/safe_price_service/tokens/clients/binance_client.py @@ -0,0 +1,47 @@ +import logging + +from .base_client import BaseHTTPClient +from .exceptions import CannotGetPrice + +logger = logging.getLogger(__name__) + + +class BinanceClient(BaseHTTPClient): # pragma: no cover + def _get_price(self, symbol: str) -> float: + url = f"https://api.binance.com/api/v3/avgPrice?symbol={symbol}" + try: + response = self.http_session.get(url, timeout=self.request_timeout) + api_json = response.json() + if not response.ok: + logger.warning("Cannot get price from url=%s", url) + raise CannotGetPrice(api_json.get("msg")) + + price = float(api_json["price"]) + if not price: + raise CannotGetPrice(f"Price from url={url} is {price}") + return price + except (ValueError, IOError) as e: + raise CannotGetPrice from e + + def get_ada_usd_price(self) -> float: + return self._get_price("ADAUSDT") + + def get_aurora_usd_price(self): + return self._get_price("NEARUSDT") + + def get_bnb_usd_price(self) -> float: + return self._get_price("BNBUSDT") + + def get_ether_usd_price(self) -> float: + """ + :return: current USD price for Ethereum + :raises: CannotGetPrice + """ + return self._get_price("ETHUSDT") + + def get_matic_usd_price(self) -> float: + """ + :return: current USD price for MATIC + :raises: CannotGetPrice + """ + return self._get_price("MATICUSDT") diff --git a/safe_price_service/tokens/clients/coingecko_client.py b/safe_price_service/tokens/clients/coingecko_client.py new file mode 100644 index 0000000..ab604b9 --- /dev/null +++ b/safe_price_service/tokens/clients/coingecko_client.py @@ -0,0 +1,152 @@ +import logging +from functools import lru_cache +from typing import Any, Dict, Optional +from urllib.parse import urljoin + +from eth_typing import ChecksumAddress + +from gnosis.eth import EthereumNetwork + +from safe_price_service.tokens.clients.base_client import BaseHTTPClient +from safe_price_service.tokens.clients.exceptions import ( + CannotGetPrice, + Coingecko404, + CoingeckoRateLimitError, + CoingeckoRequestError, +) + +logger = logging.getLogger(__name__) + + +class CoingeckoClient(BaseHTTPClient): + ASSET_BY_NETWORK = { + EthereumNetwork.ARBITRUM_ONE: "arbitrum-one", + EthereumNetwork.AURORA_MAINNET: "aurora", + EthereumNetwork.AVALANCHE_C_CHAIN: "avalanche", + EthereumNetwork.BINANCE_SMART_CHAIN_MAINNET: "binance-smart-chain", + EthereumNetwork.FUSE_MAINNET: "fuse", + EthereumNetwork.GNOSIS: "xdai", + EthereumNetwork.KCC_MAINNET: "kucoin-community-chain", + EthereumNetwork.MAINNET: "ethereum", + EthereumNetwork.METIS_ANDROMEDA_MAINNET: "metis-andromeda", + EthereumNetwork.OPTIMISM: "optimistic-ethereum", + EthereumNetwork.POLYGON: "polygon-pos", + EthereumNetwork.POLYGON_ZKEVM: "polygon-zkevm", + EthereumNetwork.CELO_MAINNET: "celo", + EthereumNetwork.METER_MAINNET: "meter", + } + base_url = "https://api.coingecko.com/" + + def __init__( + self, network: Optional[EthereumNetwork] = None, request_timeout: int = 10 + ): + super().__init__(request_timeout=request_timeout) + self.asset_platform = self.ASSET_BY_NETWORK.get(network, "ethereum") + + @classmethod + def supports_network(cls, network: EthereumNetwork): + return network in cls.ASSET_BY_NETWORK + + def _do_request(self, url: str) -> Dict[str, Any]: + try: + response = self.http_session.get(url, timeout=self.request_timeout) + if not response.ok: + if response.status_code == 404: + raise Coingecko404(url) + if response.status_code == 429: + raise CoingeckoRateLimitError(url) + raise CoingeckoRequestError(url) + return response.json() + except (ValueError, IOError) as e: + logger.warning("Problem fetching %s", url) + raise CoingeckoRequestError from e + + def _get_price(self, url: str, name: str): + try: + result = self._do_request(url) + + # Result is returned with lowercased `name` (if querying by contract address, then `token_address`) + price = result.get(name) + if price and price.get("usd"): + return price["usd"] + else: + raise CannotGetPrice(f"Price from url={url} is {price}") + except CoingeckoRequestError as e: + raise CannotGetPrice( + f"Cannot get price from Coingecko for token={name}" + ) from e + + def get_price(self, name: str) -> float: + """ + :param name: coin name + :return: usd price for token name, 0. if not found + """ + name = name.lower() + url = urljoin( + self.base_url, f"/api/v3/simple/price?ids={name}&vs_currencies=usd" + ) + return self._get_price(url, name) + + def get_token_price(self, token_address: ChecksumAddress) -> float: + """ + :param token_address: + :return: usd price for token address, 0. if not found + """ + token_address = token_address.lower() + url = urljoin( + self.base_url, + f"api/v3/simple/token_price/{self.asset_platform}?contract_addresses={token_address}&vs_currencies=usd", + ) + return self._get_price(url, token_address) + + @lru_cache(maxsize=128) + def get_token_info( + self, token_address: ChecksumAddress + ) -> Optional[Dict[str, Any]]: + token_address = token_address.lower() + url = urljoin( + self.base_url, + f"api/v3/coins/{self.asset_platform}/contract/{token_address}", + ) + try: + return self._do_request(url) + except Coingecko404: + return None + + def get_token_logo_url(self, token_address: ChecksumAddress) -> Optional[str]: + token_info = self.get_token_info(token_address) + if token_info: + return token_info["image"]["large"] + + def get_ada_usd_price(self) -> float: + return self.get_price("cardano") + + def get_avax_usd_price(self) -> float: + return self.get_price("avalanche-2") + + def get_aoa_usd_price(self) -> float: + return self.get_price("aurora") + + def get_bnb_usd_price(self) -> float: + return self.get_price("binancecoin") + + def get_ewt_usd_price(self) -> float: + return self.get_price("energy-web-token") + + def get_matic_usd_price(self) -> float: + return self.get_price("matic-network") + + def get_gather_usd_price(self) -> float: + return self.get_price("gather") + + def get_fuse_usd_price(self) -> float: + return self.get_price("fuse-network-token") + + def get_kcs_usd_price(self) -> float: + return self.get_price("kucoin-shares") + + def get_metis_usd_price(self) -> float: + return self.get_price("metis-token") + + def get_mtr_usd_price(self) -> float: + return self.get_price("meter-stable") diff --git a/safe_price_service/tokens/clients/exceptions.py b/safe_price_service/tokens/clients/exceptions.py new file mode 100644 index 0000000..15046cf --- /dev/null +++ b/safe_price_service/tokens/clients/exceptions.py @@ -0,0 +1,21 @@ +class CoingeckoRequestError(Exception): + pass + + +class Coingecko404(CoingeckoRequestError): + pass + + +class CoingeckoRateLimitError(CoingeckoRequestError): + """ + { + "status": { + "error_code": 429, + "error_message": "You've exceeded the Rate Limit. Please visit https://www.coingecko.com/en/api/pricing to subscribe to our API plans for higher rate limits." + } + } + """ + + +class CannotGetPrice(CoingeckoRequestError): + pass diff --git a/safe_price_service/tokens/clients/kleros_abi.py b/safe_price_service/tokens/clients/kleros_abi.py new file mode 100644 index 0000000..ea8ede8 --- /dev/null +++ b/safe_price_service/tokens/clients/kleros_abi.py @@ -0,0 +1,585 @@ +kleros_abi = [ + { + "constant": True, + "inputs": [], + "name": "challengePeriodDuration", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "governor", + "outputs": [{"name": "", "type": "address"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "arbitratorExtraData", + "outputs": [{"name": "", "type": "bytes"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_beneficiary", "type": "address"}, + {"name": "_request", "type": "uint256"}, + ], + "name": "amountWithdrawable", + "outputs": [{"name": "total", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_sharedStakeMultiplier", "type": "uint256"}], + "name": "changeSharedStakeMultiplier", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_beneficiary", "type": "address"}, + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_cursor", "type": "uint256"}, + {"name": "_count", "type": "uint256"}, + {"name": "_roundCursor", "type": "uint256"}, + {"name": "_roundCount", "type": "uint256"}, + ], + "name": "batchRequestWithdraw", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "loserStakeMultiplier", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "countByStatus", + "outputs": [ + {"name": "absent", "type": "uint256"}, + {"name": "registered", "type": "uint256"}, + {"name": "registrationRequest", "type": "uint256"}, + {"name": "clearingRequest", "type": "uint256"}, + {"name": "challengedRegistrationRequest", "type": "uint256"}, + {"name": "challengedClearingRequest", "type": "uint256"}, + ], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_side", "type": "uint8"}, + ], + "name": "fundAppeal", + "outputs": [], + "payable": True, + "stateMutability": "payable", + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "_tokenID", "type": "bytes32"}], + "name": "getTokenInfo", + "outputs": [ + {"name": "name", "type": "string"}, + {"name": "ticker", "type": "string"}, + {"name": "addr", "type": "address"}, + {"name": "symbolMultihash", "type": "string"}, + {"name": "status", "type": "uint8"}, + {"name": "numberOfRequests", "type": "uint256"}, + ], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_disputeID", "type": "uint256"}, + {"name": "_ruling", "type": "uint256"}, + ], + "name": "rule", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "challengerBaseDeposit", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_requesterBaseDeposit", "type": "uint256"}], + "name": "changeRequesterBaseDeposit", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [ + {"name": "_cursor", "type": "bytes32"}, + {"name": "_count", "type": "uint256"}, + {"name": "_filter", "type": "bool[8]"}, + {"name": "_oldestFirst", "type": "bool"}, + {"name": "_tokenAddr", "type": "address"}, + ], + "name": "queryTokens", + "outputs": [ + {"name": "values", "type": "bytes32[]"}, + {"name": "hasMore", "type": "bool"}, + ], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "sharedStakeMultiplier", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "", "type": "address"}, {"name": "", "type": "uint256"}], + "name": "arbitratorDisputeIDToTokenID", + "outputs": [{"name": "", "type": "bytes32"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "", "type": "uint256"}], + "name": "tokensList", + "outputs": [{"name": "", "type": "bytes32"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_request", "type": "uint256"}, + {"name": "_round", "type": "uint256"}, + {"name": "_contributor", "type": "address"}, + ], + "name": "getContributions", + "outputs": [{"name": "contributions", "type": "uint256[3]"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "arbitrator", + "outputs": [{"name": "", "type": "address"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "metaEvidenceUpdates", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "", "type": "address"}, {"name": "", "type": "uint256"}], + "name": "addressToSubmissions", + "outputs": [{"name": "", "type": "bytes32"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_beneficiary", "type": "address"}, + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_request", "type": "uint256"}, + {"name": "_cursor", "type": "uint256"}, + {"name": "_count", "type": "uint256"}, + ], + "name": "batchRoundWithdraw", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "winnerStakeMultiplier", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_evidence", "type": "string"}, + ], + "name": "challengeRequest", + "outputs": [], + "payable": True, + "stateMutability": "payable", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "requesterBaseDeposit", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "", "type": "bytes32"}], + "name": "tokens", + "outputs": [ + {"name": "name", "type": "string"}, + {"name": "ticker", "type": "string"}, + {"name": "addr", "type": "address"}, + {"name": "symbolMultihash", "type": "string"}, + {"name": "status", "type": "uint8"}, + ], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_loserStakeMultiplier", "type": "uint256"}], + "name": "changeLoserStakeMultiplier", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_request", "type": "uint256"}, + {"name": "_round", "type": "uint256"}, + ], + "name": "getRoundInfo", + "outputs": [ + {"name": "appealed", "type": "bool"}, + {"name": "paidFees", "type": "uint256[3]"}, + {"name": "hasPaid", "type": "bool[3]"}, + {"name": "feeRewards", "type": "uint256"}, + ], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "tokenCount", + "outputs": [{"name": "count", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_name", "type": "string"}, + {"name": "_ticker", "type": "string"}, + {"name": "_addr", "type": "address"}, + {"name": "_symbolMultihash", "type": "string"}, + ], + "name": "requestStatusChange", + "outputs": [], + "payable": True, + "stateMutability": "payable", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_beneficiary", "type": "address"}, + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_request", "type": "uint256"}, + {"name": "_round", "type": "uint256"}, + ], + "name": "withdrawFeesAndRewards", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_winnerStakeMultiplier", "type": "uint256"}], + "name": "changeWinnerStakeMultiplier", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_arbitrator", "type": "address"}, + {"name": "_arbitratorExtraData", "type": "bytes"}, + ], + "name": "changeArbitrator", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "_tokenID", "type": "bytes32"}], + "name": "isPermitted", + "outputs": [{"name": "allowed", "type": "bool"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": True, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_request", "type": "uint256"}, + ], + "name": "getRequestInfo", + "outputs": [ + {"name": "disputed", "type": "bool"}, + {"name": "disputeID", "type": "uint256"}, + {"name": "submissionTime", "type": "uint256"}, + {"name": "resolved", "type": "bool"}, + {"name": "parties", "type": "address[3]"}, + {"name": "numberOfRounds", "type": "uint256"}, + {"name": "ruling", "type": "uint8"}, + {"name": "arbitrator", "type": "address"}, + {"name": "arbitratorExtraData", "type": "bytes"}, + ], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_challengePeriodDuration", "type": "uint256"}], + "name": "changeTimeToChallenge", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": True, + "inputs": [], + "name": "MULTIPLIER_DIVISOR", + "outputs": [{"name": "", "type": "uint256"}], + "payable": False, + "stateMutability": "view", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_registrationMetaEvidence", "type": "string"}, + {"name": "_clearingMetaEvidence", "type": "string"}, + ], + "name": "changeMetaEvidence", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_challengerBaseDeposit", "type": "uint256"}], + "name": "changeChallengerBaseDeposit", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_governor", "type": "address"}], + "name": "changeGovernor", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [{"name": "_tokenID", "type": "bytes32"}], + "name": "executeRequest", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_tokenID", "type": "bytes32"}, + {"name": "_evidence", "type": "string"}, + ], + "name": "submitEvidence", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"name": "_arbitrator", "type": "address"}, + {"name": "_arbitratorExtraData", "type": "bytes"}, + {"name": "_registrationMetaEvidence", "type": "string"}, + {"name": "_clearingMetaEvidence", "type": "string"}, + {"name": "_governor", "type": "address"}, + {"name": "_requesterBaseDeposit", "type": "uint256"}, + {"name": "_challengerBaseDeposit", "type": "uint256"}, + {"name": "_challengePeriodDuration", "type": "uint256"}, + {"name": "_sharedStakeMultiplier", "type": "uint256"}, + {"name": "_winnerStakeMultiplier", "type": "uint256"}, + {"name": "_loserStakeMultiplier", "type": "uint256"}, + ], + "payable": False, + "stateMutability": "nonpayable", + "type": "constructor", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": False, "name": "_name", "type": "string"}, + {"indexed": False, "name": "_ticker", "type": "string"}, + {"indexed": False, "name": "_symbolMultihash", "type": "string"}, + {"indexed": True, "name": "_address", "type": "address"}, + ], + "name": "TokenSubmitted", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_tokenID", "type": "bytes32"}, + {"indexed": False, "name": "_registrationRequest", "type": "bool"}, + ], + "name": "RequestSubmitted", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_requester", "type": "address"}, + {"indexed": True, "name": "_challenger", "type": "address"}, + {"indexed": True, "name": "_tokenID", "type": "bytes32"}, + {"indexed": False, "name": "_status", "type": "uint8"}, + {"indexed": False, "name": "_disputed", "type": "bool"}, + {"indexed": False, "name": "_appealed", "type": "bool"}, + ], + "name": "TokenStatusChange", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_tokenID", "type": "bytes32"}, + {"indexed": True, "name": "_contributor", "type": "address"}, + {"indexed": True, "name": "_request", "type": "uint256"}, + {"indexed": False, "name": "_round", "type": "uint256"}, + {"indexed": False, "name": "_value", "type": "uint256"}, + ], + "name": "RewardWithdrawal", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_metaEvidenceID", "type": "uint256"}, + {"indexed": False, "name": "_evidence", "type": "string"}, + ], + "name": "MetaEvidence", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_arbitrator", "type": "address"}, + {"indexed": True, "name": "_disputeID", "type": "uint256"}, + {"indexed": False, "name": "_metaEvidenceID", "type": "uint256"}, + {"indexed": False, "name": "_evidenceGroupID", "type": "uint256"}, + ], + "name": "Dispute", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_arbitrator", "type": "address"}, + {"indexed": True, "name": "_evidenceGroupID", "type": "uint256"}, + {"indexed": True, "name": "_party", "type": "address"}, + {"indexed": False, "name": "_evidence", "type": "string"}, + ], + "name": "Evidence", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "_arbitrator", "type": "address"}, + {"indexed": True, "name": "_disputeID", "type": "uint256"}, + {"indexed": False, "name": "_ruling", "type": "uint256"}, + ], + "name": "Ruling", + "type": "event", + }, +] diff --git a/safe_price_service/tokens/clients/kleros_client.py b/safe_price_service/tokens/clients/kleros_client.py new file mode 100644 index 0000000..b1be366 --- /dev/null +++ b/safe_price_service/tokens/clients/kleros_client.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from typing import List, Sequence + +from hexbytes import HexBytes + +from gnosis.eth import EthereumClient +from gnosis.eth.constants import NULL_ADDRESS + +from .kleros_abi import kleros_abi + + +@dataclass +class KlerosToken: + name: str + ticker: str + address: str + symbol_multihash: str + status: int + number_of_requests: int + + +class KlerosClient: + """ + https://github.com/kleros/kleros-interaction/blob/master/contracts/standard/permission/ArbitrableTokenList.sol + https://github.com/kleros/t2cr-badges-example/blob/master/docs/deep-dive.md + """ + + abi = kleros_abi + mainnet_address = "0xEbcf3bcA271B26ae4B162Ba560e243055Af0E679" + null_token_id = b"\x00" * 32 # Empty bytes32 for null tokens + + def __init__(self, ethereum_client: EthereumClient): + self.ethereum_client = ethereum_client + self.kleros_contract = ethereum_client.erc20.slow_w3.eth.contract( + self.mainnet_address, abi=self.abi + ) + + def get_token_count(self) -> int: + return self.kleros_contract.functions.tokenCount().call() + + def get_token_ids(self) -> Sequence[bytes]: + """ + /** @dev Return the values of the tokens the query finds. This function is O(n), where n is the number + of tokens. This could exceed the gas limit, therefore this function should only be used for interface + display and not by other contracts. + * @param _cursor The ID of the token from which to start iterating. To start from either the oldest + or newest item. + * @param _count The number of tokens to return. + * @param _filter The filter to use. Each element of the array in sequence means: + * - Include absent tokens in result. + * - Include registered tokens in result. + * - Include tokens with registration requests that are not disputed in result. + * - Include tokens with clearing requests that are not disputed in result. + * - Include disputed tokens with registration requests in result. + * - Include disputed tokens with clearing requests in result. + * - Include tokens submitted by the caller. + * - Include tokens challenged by the caller. + * @param _oldestFirst Whether to sort from oldest to the newest item. + * @param _tokenAddr A token address to filter submissions by address (optional). + * @return The values of the tokens found and whether there are more tokens for the current filter and sort. + */ + """ + token_count = self.get_token_count() + token_ids: List[bytes] + has_more: bool + token_ids, has_more = self.kleros_contract.functions.queryTokens( + HexBytes("0" * 64), # bytes32 + token_count, + [ + False, # Include absent tokens in result. + True, # Include registered tokens in result. + False, # Include tokens with registration requests that are not disputed in result. + False, # Include tokens with clearing requests that are not disputed in result. + False, # Include disputed tokens with registration requests in result. + False, # Include disputed tokens with clearing requests in result. + False, # Include tokens submitted by the caller. + False, # Include tokens challenged by the caller. + ], + False, + NULL_ADDRESS, + ).call() + return [token_id for token_id in token_ids if token_id != self.null_token_id] + + def get_token_info(self, token_ids: Sequence[bytes]) -> Sequence[KlerosToken]: + queries = [] + for token_id in token_ids: + queries.append(self.kleros_contract.functions.getTokenInfo(token_id)) + + # name string, ticker string, addr address, symbolMultihash string, status uint8, numberOfRequests uint256 + token_infos = self.ethereum_client.batch_call(queries) + return [KlerosToken(*token_info) for token_info in token_infos] + + def get_tokens_with_info(self) -> Sequence[KlerosToken]: + return self.get_token_info(self.get_token_ids()) diff --git a/safe_price_service/tokens/clients/kraken_client.py b/safe_price_service/tokens/clients/kraken_client.py new file mode 100644 index 0000000..8e593c9 --- /dev/null +++ b/safe_price_service/tokens/clients/kraken_client.py @@ -0,0 +1,72 @@ +import logging + +from .base_client import BaseHTTPClient +from .exceptions import CannotGetPrice + +logger = logging.getLogger(__name__) + + +class KrakenClient(BaseHTTPClient): + def _get_price(self, symbol: str) -> float: + url = f"https://api.kraken.com/0/public/Ticker?pair={symbol}" + try: + response = self.http_session.get(url, timeout=self.request_timeout) + api_json = response.json() + error = api_json.get("error") + if not response.ok or error: + logger.warning("Cannot get price from url=%s", url) + raise CannotGetPrice(str(api_json["error"])) + + result = api_json["result"] + for new_ticker in result: + price = float(result[new_ticker]["c"][0]) + if not price: + raise CannotGetPrice(f"Price from url={url} is {price}") + return price + except (ValueError, IOError) as e: + raise CannotGetPrice from e + + def get_ada_usd_price(self) -> float: + return self._get_price("ADAUSD") + + def get_avax_usd_price(self) -> float: + """ + :return: current USD price for AVAX + :raises: CannotGetPrice + """ + return self._get_price("AVAXUSD") + + def get_dai_usd_price(self) -> float: + """ + :return: current USD price for DAI + :raises: CannotGetPrice + """ + return self._get_price("DAIUSD") + + def get_ether_usd_price(self) -> float: + """ + :return: current USD price for Ethereum + :raises: CannotGetPrice + """ + return self._get_price("ETHUSD") + + def get_matic_usd_price(self): + """ + :return: current USD price for MATIC + :raises: CannotGetPrice + """ + return self._get_price("MATICUSD") + + def get_ewt_usd_price(self) -> float: + """ + :return: current USD price for Energy Web Token + :raises: CannotGetPrice + """ + return self._get_price("EWTUSD") + + def get_algo_usd_price(self): + """ + :return: current USD price for Algorand + :raises: CannotGetPrice + """ + return self._get_price("ALGOUSD") diff --git a/safe_price_service/tokens/clients/kucoin_client.py b/safe_price_service/tokens/clients/kucoin_client.py new file mode 100644 index 0000000..b4aa4a4 --- /dev/null +++ b/safe_price_service/tokens/clients/kucoin_client.py @@ -0,0 +1,89 @@ +import logging + +from .base_client import BaseHTTPClient +from .exceptions import CannotGetPrice + +logger = logging.getLogger(__name__) + + +class KucoinClient(BaseHTTPClient): + def _get_price(self, symbol: str): + url = f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={symbol}" + + try: + response = self.http_session.get(url, timeout=self.request_timeout) + result = response.json() + return float(result["data"]["price"]) + except (ValueError, IOError) as e: + logger.warning("Cannot get price from url=%s", url) + raise CannotGetPrice from e + + def get_ether_usd_price(self) -> float: + """ + :return: current USD price for ETH Coin + :raises: CannotGetPrice + """ + return self._get_price("ETH-USDT") + + def get_aurora_usd_price(self) -> float: + """ + :return: current USD price for Aurora Coin + :raises: CannotGetPrice + """ + return self._get_price("AURORA-USDT") + + def get_bnb_usd_price(self) -> float: + """ + :return: current USD price for Binance Coin + :raises: CannotGetPrice + """ + return self._get_price("BNB-USDT") + + def get_celo_usd_price(self) -> float: + """ + :return: current USD price for Celo + :raises: CannotGetPrice + """ + return self._get_price("CELO-USDT") + + def get_cro_usd_price(self) -> float: + """ + :return: current USD price for Cronos + :raises: CannotGetPrice + """ + return self._get_price("CRO-USDT") + + def get_ewt_usd_price(self) -> float: + """ + :return: current USD price for Energy Web Token + :raises: CannotGetPrice + """ + return self._get_price("EWT-USDT") + + def get_kcs_usd_price(self) -> float: + """ + :return: current USD price for KuCoin Token + :raises: CannotGetPrice + """ + return self._get_price("KCS-USDT") + + def get_matic_usd_price(self) -> float: + """ + :return: current USD price for MATIC Token + :raises: CannotGetPrice + """ + return self._get_price("MATIC-USDT") + + def get_xdc_usd_price(self) -> float: + """ + :return: current USD price for XDC Token + :raises: CannotGetPrice + """ + return self._get_price("XDC-USDT") + + def get_ftm_usd_price(self) -> float: + """ + :return: current USD price for FTM Token + :raises: CannotGetPrice + """ + return self._get_price("FTM-USDT") diff --git a/safe_price_service/tokens/clients/zerion_client.py b/safe_price_service/tokens/clients/zerion_client.py new file mode 100644 index 0000000..32c6c19 --- /dev/null +++ b/safe_price_service/tokens/clients/zerion_client.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass +from typing import List, Optional + +from eth_typing import ChecksumAddress +from web3.exceptions import ContractLogicError + +from gnosis.eth import EthereumClient +from gnosis.eth.constants import NULL_ADDRESS + + +@dataclass +class UniswapComponent: + address: str + tokenType: str # `ERC20` by default + rate: str # price per full share (1e18) + + +@dataclass +class ZerionPoolMetadata: + address: ChecksumAddress + name: str + symbol: str + decimals: int + + +class ZerionTokenAdapterClient: + """ + Client for Zerion Token Adapter + https://github.com/zeriontech/defi-sdk + """ + + ABI = [ + { + "inputs": [{"internalType": "address", "name": "token", "type": "address"}], + "name": "getComponents", + "outputs": [ + { + "components": [ + {"internalType": "address", "name": "token", "type": "address"}, + { + "internalType": "string", + "name": "tokenType", + "type": "string", + }, + {"internalType": "uint256", "name": "rate", "type": "uint256"}, + ], + "internalType": "struct Component[]", + "name": "", + "type": "tuple[]", + } + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "address", "name": "token", "type": "address"}], + "name": "getMetadata", + "outputs": [ + { + "components": [ + {"internalType": "address", "name": "token", "type": "address"}, + {"internalType": "string", "name": "name", "type": "string"}, + {"internalType": "string", "name": "symbol", "type": "string"}, + {"internalType": "uint8", "name": "decimals", "type": "uint8"}, + ], + "internalType": "struct TokenMetadata", + "name": "", + "type": "tuple", + } + ], + "stateMutability": "view", + "type": "function", + }, + ] + ADAPTER_ADDRESS: ChecksumAddress = ChecksumAddress(NULL_ADDRESS) + + def __init__( + self, + ethereum_client: EthereumClient, + adapter_address: Optional[ChecksumAddress] = None, + ): + self.ethereum_client = ethereum_client + self.adapter_address = ( + adapter_address if adapter_address else self.ADAPTER_ADDRESS + ) + self.contract = ethereum_client.w3.eth.contract( + self.adapter_address, abi=self.ABI + ) + + def get_components( + self, token_address: ChecksumAddress + ) -> Optional[List[UniswapComponent]]: + try: + return [ + UniswapComponent(*component) + for component in self.contract.functions.getComponents( + token_address + ).call() + ] + except ContractLogicError: + return None + + def get_metadata( + self, token_address: ChecksumAddress + ) -> Optional[ZerionPoolMetadata]: + try: + return ZerionPoolMetadata( + *self.contract.functions.getMetadata(token_address).call() + ) + except ContractLogicError: + return None + + +class ZerionUniswapV2TokenAdapterClient(ZerionTokenAdapterClient): + ADAPTER_ADDRESS: ChecksumAddress = ChecksumAddress( + "0x6C5D49157863f942A5E6115aaEAb7d6A67a852d3" + ) + + +class BalancerTokenAdapterClient(ZerionTokenAdapterClient): + ADAPTER_ADDRESS: ChecksumAddress = ChecksumAddress( + "0xb45c5AE417F70E4C52DFB784569Ce843a45FE8ca" + ) diff --git a/safe_price_service/tokens/exceptions.py b/safe_price_service/tokens/exceptions.py new file mode 100644 index 0000000..296a833 --- /dev/null +++ b/safe_price_service/tokens/exceptions.py @@ -0,0 +1,2 @@ +class TokenListRetrievalException(Exception): + pass diff --git a/safe_price_service/tokens/management/__init__.py b/safe_price_service/tokens/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/tokens/management/commands/__init__.py b/safe_price_service/tokens/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/tokens/serializers.py b/safe_price_service/tokens/serializers.py new file mode 100644 index 0000000..aeefe11 --- /dev/null +++ b/safe_price_service/tokens/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from .services.price_service import FiatPriceWithTimestamp + + +class TokenPriceResponseSerializer(serializers.Serializer): + fiat_code = serializers.SerializerMethodField() + fiat_price = serializers.CharField() + timestamp = serializers.DateTimeField() + + def get_fiat_code(self, obj: FiatPriceWithTimestamp): + return obj.fiat_code.name diff --git a/safe_price_service/tokens/services/__init__.py b/safe_price_service/tokens/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/tokens/services/price_service.py b/safe_price_service/tokens/services/price_service.py new file mode 100644 index 0000000..362c596 --- /dev/null +++ b/safe_price_service/tokens/services/price_service.py @@ -0,0 +1,453 @@ +import operator +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from functools import cache, cached_property +from logging import getLogger +from typing import Dict, List, Optional, Tuple + +from django.conf import settings +from django.utils import timezone + +from cachetools import TTLCache, cachedmethod +from eth.constants import ZERO_ADDRESS +from eth_typing import ChecksumAddress + +from gnosis.eth import EthereumClient +from gnosis.eth.constants import NULL_ADDRESS +from gnosis.eth.ethereum_client import EthereumNetwork +from gnosis.eth.oracles import ( + AaveOracle, + BalancerOracle, + ComposedPriceOracle, + CowswapOracle, + CurveOracle, + EnzymeOracle, + KyberOracle, + MooniswapOracle, + OracleException, + PoolTogetherOracle, + PriceOracle, + PricePoolOracle, + SuperfluidOracle, + SushiswapOracle, + UnderlyingToken, + UniswapV2Oracle, + UniswapV3Oracle, + YearnOracle, +) + +from ..clients import CannotGetPrice, CoingeckoClient, KrakenClient, KucoinClient + +logger = getLogger(__name__) + + +class FiatCode(Enum): + USD = 1 + EUR = 2 + + +@dataclass +class FiatPriceWithTimestamp: + """ + Contains fiat price and when was calculated + """ + + fiat_price: float + fiat_code: FiatCode + timestamp: datetime + + +@cache +def get_price_services() -> Dict[int, "PriceService"]: + price_services = {} + for node_url in settings.ETHEREUM_NODES_URLS: + ethereum_client = EthereumClient(node_url) + try: + chain_id = ethereum_client.get_chain_id() + price_services[chain_id] = PriceService(ethereum_client) + logger.info( + "Loaded price service for chain-id=%d on node-url=%s", + chain_id, + node_url, + ) + except IOError: + logger.error("Problem connecting to node-url=%s", node_url) + return price_services + + +def is_chain_supported(chain_id: int) -> bool: + return chain_id in get_price_services() + + +def get_price_service(chain_id: int) -> Optional["PriceService"]: + return get_price_services().get(chain_id) + + +class PriceService: + def __init__(self, ethereum_client: EthereumClient): + self.ethereum_client = ethereum_client + self.ethereum_network = self.ethereum_client.get_network() + self.coingecko_client = CoingeckoClient(self.ethereum_network) + self.kraken_client = KrakenClient() + self.kucoin_client = KucoinClient() + + # Caches + self.prices_cache_ttl_minutes: int = settings.PRICES_CACHE_TTL_MINUTES + self.cache_native_coin_usd_price = TTLCache( + maxsize=2048, ttl=60 * self.prices_cache_ttl_minutes + ) + self.cache_token_eth_value = TTLCache( + maxsize=2048, ttl=60 * self.prices_cache_ttl_minutes + ) + self.cache_token_coingecko_usd_value = TTLCache( + maxsize=2048, ttl=60 * self.prices_cache_ttl_minutes + ) + self.cache_token_eth_value_from_composed_oracles = TTLCache( + maxsize=2048, ttl=60 * self.prices_cache_ttl_minutes + ) + self.cache_token_usd_price = TTLCache( + maxsize=50_000, ttl=60 * self.prices_cache_ttl_minutes + ) + + @cached_property + def enabled_price_oracles(self) -> Tuple[PriceOracle]: + oracles = tuple( + Oracle(self.ethereum_client) + for Oracle in ( + UniswapV3Oracle, + CowswapOracle, + UniswapV2Oracle, + SushiswapOracle, + KyberOracle, + ) + if Oracle.is_available(self.ethereum_client) + ) + if oracles: + if AaveOracle.is_available(self.ethereum_client): + oracles += (AaveOracle(self.ethereum_client, oracles[0]),) + if SuperfluidOracle.is_available(self.ethereum_client): + oracles += (SuperfluidOracle(self.ethereum_client, oracles[0]),) + + return oracles + + @cached_property + def enabled_price_pool_oracles(self) -> Tuple[PricePoolOracle]: + if not self.enabled_price_oracles: + return tuple() + oracles = tuple( + Oracle(self.ethereum_client, self.enabled_price_oracles[0]) + for Oracle in ( + BalancerOracle, + MooniswapOracle, + ) + ) + + if UniswapV2Oracle.is_available(self.ethereum_client): + # Uses a different constructor that others pool oracles + oracles = (UniswapV2Oracle(self.ethereum_client),) + oracles + return oracles + + @cached_property + def enabled_composed_price_oracles(self) -> Tuple[ComposedPriceOracle]: + return tuple( + Oracle(self.ethereum_client) + for Oracle in (CurveOracle, YearnOracle, PoolTogetherOracle, EnzymeOracle) + if Oracle.is_available(self.ethereum_client) + ) + + def get_avalanche_usd_price(self) -> float: + try: + return self.kraken_client.get_avax_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_avax_usd_price() + + def get_aurora_usd_price(self) -> float: + try: + return self.kucoin_client.get_aurora_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_aoa_usd_price() + + def get_cardano_usd_price(self) -> float: + try: + return self.kraken_client.get_ada_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_ada_usd_price() + + def get_algorand_usd_price(self) -> float: + return self.kraken_client.get_algo_usd_price() + + def get_binance_usd_price(self) -> float: + try: + return self.kucoin_client.get_bnb_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_bnb_usd_price() + + def get_ewt_usd_price(self) -> float: + try: + return self.kraken_client.get_ewt_usd_price() + except CannotGetPrice: + try: + return self.kucoin_client.get_ewt_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_ewt_usd_price() + + def get_matic_usd_price(self) -> float: + try: + return self.kraken_client.get_matic_usd_price() + except CannotGetPrice: + try: + return self.kucoin_client.get_matic_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_matic_usd_price() + + def get_cronos_usd_price(self) -> float: + return self.kucoin_client.get_cro_usd_price() + + def get_xdc_usd_price(self) -> float: + return self.kucoin_client.get_xdc_usd_price() + + def get_ftm_usd_price(self) -> float: + return self.kucoin_client.get_ftm_usd_price() + + def get_kcs_usd_price(self) -> float: + try: + return self.kucoin_client.get_kcs_usd_price() + except CannotGetPrice: + return self.coingecko_client.get_kcs_usd_price() + + def get_mtr_usd_price(self) -> float: + return self.coingecko_client.get_mtr_usd_price() + + def get_ether_usd_price(self) -> float: + try: + return self.kraken_client.get_ether_usd_price() + except CannotGetPrice: + return self.kucoin_client.get_ether_usd_price() + + @cachedmethod(cache=operator.attrgetter("cache_native_coin_usd_price")) + def get_native_coin_usd_price(self) -> float: + """ + Get USD price for native coin. It depends on the ethereum network: + - On mainnet, use ETH/USD + - On xDAI, use DAI/USD. + - On Polygon, use Matic + - ... + + :return: USD price for chain native coin + """ + if self.ethereum_network == EthereumNetwork.GNOSIS: + try: + return self.kraken_client.get_dai_usd_price() + except CannotGetPrice: + return 1 # DAI/USD should be close to 1 + elif self.ethereum_network in ( + EthereumNetwork.ENERGY_WEB_CHAIN, + EthereumNetwork.ENERGY_WEB_VOLTA_TESTNET, + ): + return self.get_ewt_usd_price() + elif self.ethereum_network in (EthereumNetwork.POLYGON, EthereumNetwork.MUMBAI): + return self.get_matic_usd_price() + elif self.ethereum_network == EthereumNetwork.BINANCE_SMART_CHAIN_MAINNET: + return self.get_binance_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.GATHER_DEVNET_NETWORK, + EthereumNetwork.GATHER_TESTNET_NETWORK, + EthereumNetwork.GATHER_MAINNET_NETWORK, + ): + return self.coingecko_client.get_gather_usd_price() + elif self.ethereum_network == EthereumNetwork.AVALANCHE_C_CHAIN: + return self.get_avalanche_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.MILKOMEDA_C1_TESTNET, + EthereumNetwork.MILKOMEDA_C1_MAINNET, + ): + return self.get_cardano_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.AURORA_MAINNET, + EthereumNetwork.ARBITRUM_RINKEBY, + ): + return self.get_aurora_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.CRONOS_TESTNET, + EthereumNetwork.CRONOS_MAINNET_BETA, + ): + return self.get_cronos_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.FUSE_MAINNET, + EthereumNetwork.FUSE_SPARKNET, + ): + return self.coingecko_client.get_fuse_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.KCC_MAINNET, + EthereumNetwork.KCC_TESTNET, + ): + return self.get_kcs_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.METIS_ANDROMEDA_MAINNET, + EthereumNetwork.METIS_GOERLI_TESTNET, + EthereumNetwork.METIS_STARDUST_TESTNET, + ): + return self.coingecko_client.get_metis_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.MILKOMEDA_A1_TESTNET, + EthereumNetwork.MILKOMEDA_A1_MAINNET, + ): + return self.get_algorand_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.CELO_MAINNET, + EthereumNetwork.CELO_ALFAJORES_TESTNET, + EthereumNetwork.CELO_BAKLAVA_TESTNET, + ): + return self.kucoin_client.get_celo_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.XINFIN_XDC_NETWORK, + EthereumNetwork.XDC_APOTHEM_NETWORK, + ): + return self.get_xdc_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.METER_MAINNET, + EthereumNetwork.METER_TESTNET, + ): + return self.coingecko_client.get_mtr_usd_price() + elif self.ethereum_network in ( + EthereumNetwork.FANTOM_OPERA, + EthereumNetwork.FANTOM_TESTNET, + ): + return self.get_ftm_usd_price() + else: + return self.get_ether_usd_price() + + @cachedmethod(cache=operator.attrgetter("cache_token_eth_value")) + def get_token_eth_value_from_oracles(self, token_address: ChecksumAddress) -> float: + """ + Uses multiple decentralized and centralized oracles to get token prices relative to the native coin + + :param token_address: + :return: Current ether value for a given `token_address` + """ + if token_address in ( + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", # Used by some oracles + NULL_ADDRESS, + ): # Ether + return 1.0 + + for oracle in self.enabled_price_oracles: + try: + eth_value = oracle.get_price(token_address) + logger.info( + "Retrieved eth-value=%.4f for token-address=%s from %s", + eth_value, + token_address, + oracle.__class__.__name__, + ) + return eth_value + except OracleException: + logger.debug( + "Cannot get eth value for token-address=%s from %s", + token_address, + oracle.__class__.__name__, + ) + + # Try pool tokens + for oracle in self.enabled_price_pool_oracles: + try: + eth_value = oracle.get_pool_token_price(token_address) + logger.info( + "Retrieved eth-value=%.4f for token-address=%s from %s", + eth_value, + token_address, + oracle.__class__.__name__, + ) + return eth_value + except OracleException: + logger.debug( + "Cannot get eth value for token-address=%s from %s", + token_address, + oracle.__class__.__name__, + ) + + logger.warning("Cannot find eth value for token-address=%s", token_address) + return 0.0 + + @cachedmethod(cache=operator.attrgetter("cache_token_coingecko_usd_value")) + def get_token_usd_price_from_coingecko( + self, token_address: ChecksumAddress + ) -> float: + """ + :param token_address: + :return: usd value for a given `token_address` using Coingecko + """ + if self.coingecko_client.supports_network(self.ethereum_network): + try: + return self.coingecko_client.get_token_price(token_address) + except CannotGetPrice: + pass + return 0.0 + + def get_underlying_tokens( + self, token_address: ChecksumAddress + ) -> Optional[List[UnderlyingToken]]: + """ + :param token_address: + :return: usd value for a given `token_address` using Curve, if not use Coingecko as last resource + """ + for oracle in self.enabled_composed_price_oracles: + try: + underlying_tokens = oracle.get_underlying_tokens(token_address) + logger.info( + "Retrieved underlying tokens %s for token-address=%s from %s", + underlying_tokens, + token_address, + oracle.__class__.__name__, + ) + return underlying_tokens + except OracleException: + logger.debug( + "Cannot get an underlying token for token-address=%s from %s", + token_address, + oracle.__class__.__name__, + ) + + @cachedmethod( + cache=operator.attrgetter("cache_token_eth_value_from_composed_oracles") + ) + def get_token_eth_value_from_composed_oracles( + self, token_address: ChecksumAddress + ) -> float: + """ + :param token_address + :return: Token/Ether price from composed oracles + """ + eth_price = 0 + if underlying_tokens := self.get_underlying_tokens(token_address): + for underlying_token in underlying_tokens: + # Find underlying token price and multiply by quantity + address = underlying_token.address + eth_price += ( + self.get_token_eth_value_from_oracles(address) + * underlying_token.quantity + ) + + return eth_price + + @cachedmethod(cache=operator.attrgetter("cache_token_usd_price")) + def get_token_usd_price( + self, token_address: ChecksumAddress + ) -> FiatPriceWithTimestamp: + """ + :param token_address. If ``0x0...0`` address is provided, native coin price will be returned + :return: Usd price for provided token + """ + if token_address == ZERO_ADDRESS: + usd_price = self.get_native_coin_usd_price() + else: + eth_value = self.get_token_eth_value_from_oracles( + token_address + ) or self.get_token_eth_value_from_composed_oracles(token_address) + if eth_value: + usd_price = eth_value * self.get_native_coin_usd_price() + else: + usd_price = self.get_token_usd_price_from_coingecko(token_address) + + return FiatPriceWithTimestamp(usd_price, FiatCode.USD, timezone.now()) diff --git a/safe_price_service/tokens/tests/__init__.py b/safe_price_service/tokens/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/tokens/tests/clients/__init__.py b/safe_price_service/tokens/tests/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/tokens/tests/clients/test_clients.py b/safe_price_service/tokens/tests/clients/test_clients.py new file mode 100644 index 0000000..d83b348 --- /dev/null +++ b/safe_price_service/tokens/tests/clients/test_clients.py @@ -0,0 +1,88 @@ +from unittest import mock + +from django.test import TestCase + +from requests import Session + +from gnosis.eth.tests.utils import just_test_if_mainnet_node + +from ...clients import CannotGetPrice, CoingeckoClient, KrakenClient, KucoinClient + + +class TestClients(TestCase): + def test_get_bnb_usd_price(self) -> float: + just_test_if_mainnet_node() + kucoin_client = KucoinClient() + coingecko_client = CoingeckoClient() + + price = kucoin_client.get_bnb_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + price = coingecko_client.get_bnb_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + def test_get_dai_usd_price_kraken(self) -> float: + just_test_if_mainnet_node() + kraken_client = KrakenClient() + + # Kraken is used + price = kraken_client.get_dai_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + def test_get_ether_usd_price_kraken(self): + just_test_if_mainnet_node() + kraken_client = KrakenClient() + + # Kraken is used + eth_usd_price = kraken_client.get_ether_usd_price() + self.assertIsInstance(eth_usd_price, float) + self.assertGreater(eth_usd_price, 0) + + def test_get_ewt_usd_price_kraken(self) -> float: + just_test_if_mainnet_node() + kraken_client = KrakenClient() + + # Kraken is used + price = kraken_client.get_ewt_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + def test_get_ether_usd_price_kucoin(self): + just_test_if_mainnet_node() + kucoin_client = KucoinClient() + + eth_usd_price = kucoin_client.get_ether_usd_price() + self.assertIsInstance(eth_usd_price, float) + self.assertGreater(eth_usd_price, 0) + + def test_get_matic_usd_price(self) -> float: + just_test_if_mainnet_node() + + for provider in [KucoinClient(), KrakenClient(), CoingeckoClient()]: + with self.subTest(provider=provider): + price = provider.get_matic_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + def test_get_ewt_usd_price_coingecko(self) -> float: + just_test_if_mainnet_node() + coingecko_client = CoingeckoClient() + + price = coingecko_client.get_ewt_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + def test_get_ewt_usd_price_kucoin(self) -> float: + just_test_if_mainnet_node() + kucoin_client = KucoinClient() + + price = kucoin_client.get_ewt_usd_price() + self.assertIsInstance(price, float) + self.assertGreater(price, 0) + + with mock.patch.object(Session, "get", side_effect=IOError("Connection Error")): + with self.assertRaises(CannotGetPrice): + kucoin_client.get_ewt_usd_price() diff --git a/safe_price_service/tokens/tests/clients/test_coingecko_client.py b/safe_price_service/tokens/tests/clients/test_coingecko_client.py new file mode 100644 index 0000000..c7e9811 --- /dev/null +++ b/safe_price_service/tokens/tests/clients/test_coingecko_client.py @@ -0,0 +1,89 @@ +import functools + +from django.test import TestCase + +import pytest + +from gnosis.eth import EthereumNetwork + +from ...clients import CannotGetPrice +from ...clients.coingecko_client import CoingeckoClient +from ...clients.exceptions import CoingeckoRateLimitError + + +# FIXME Remove it +def skip_on(exception, reason="Test skipped due to a controlled exception"): + """ + Decorator to skip a test if an exception is raised instead of failing it + + :param exception: + :param reason: + :return: + """ + + def decorator_func(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + try: + # Run the test + return f(*args, **kwargs) + except exception: + pytest.skip(reason) + + return wrapper + + return decorator_func + + +class TestCoingeckoClient(TestCase): + GNO_TOKEN_ADDRESS = "0x6810e776880C02933D47DB1b9fc05908e5386b96" + GNO_GNOSIS_CHAIN_ADDRESS = "0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb" + + @skip_on(CannotGetPrice, reason="Cannot get price from Coingecko") + def test_coingecko_client(self): + self.assertTrue(CoingeckoClient.supports_network(EthereumNetwork.MAINNET)) + self.assertTrue( + CoingeckoClient.supports_network( + EthereumNetwork.BINANCE_SMART_CHAIN_MAINNET + ) + ) + self.assertTrue(CoingeckoClient.supports_network(EthereumNetwork.POLYGON)) + self.assertTrue(CoingeckoClient.supports_network(EthereumNetwork.GNOSIS)) + + # Test Mainnet + coingecko_client = CoingeckoClient() + non_existing_token_address = "0xda2f8b8386302C354a90DB670E40beA3563AF454" + self.assertGreater(coingecko_client.get_token_price(self.GNO_TOKEN_ADDRESS), 0) + with self.assertRaises(CannotGetPrice): + coingecko_client.get_token_price(non_existing_token_address) + + # Test Binance + bsc_coingecko_client = CoingeckoClient( + EthereumNetwork.BINANCE_SMART_CHAIN_MAINNET + ) + binance_peg_ethereum_address = "0x2170Ed0880ac9A755fd29B2688956BD959F933F8" + self.assertGreater( + bsc_coingecko_client.get_token_price(binance_peg_ethereum_address), 0 + ) + + # Test Polygon + polygon_coingecko_client = CoingeckoClient(EthereumNetwork.POLYGON) + bnb_pos_address = "0xb33EaAd8d922B1083446DC23f610c2567fB5180f" + self.assertGreater(polygon_coingecko_client.get_token_price(bnb_pos_address), 0) + + @skip_on(CoingeckoRateLimitError, reason="Coingecko rate limit reached") + def test_get_logo_url(self): + # Test Mainnet + coingecko_client = CoingeckoClient() + self.assertIn( + "http", coingecko_client.get_token_logo_url(self.GNO_TOKEN_ADDRESS) + ) + self.assertIsNone( + coingecko_client.get_token_logo_url(self.GNO_GNOSIS_CHAIN_ADDRESS) + ) + + # Test Gnosis Chain + coingecko_client = CoingeckoClient(EthereumNetwork.GNOSIS) + self.assertIn( + "http", coingecko_client.get_token_logo_url(self.GNO_GNOSIS_CHAIN_ADDRESS) + ) diff --git a/safe_price_service/tokens/tests/clients/test_kleros_client.py b/safe_price_service/tokens/tests/clients/test_kleros_client.py new file mode 100644 index 0000000..e7b9af8 --- /dev/null +++ b/safe_price_service/tokens/tests/clients/test_kleros_client.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from gnosis.eth import EthereumClient +from gnosis.eth.tests.utils import just_test_if_mainnet_node +from gnosis.eth.utils import fast_is_checksum_address + +from ...clients.kleros_client import KlerosClient + + +class TestKlerosClient(TestCase): + def test_kleros_client(self): + mainnet_node = just_test_if_mainnet_node() + kleros_client = KlerosClient(EthereumClient(mainnet_node)) + + token_ids = kleros_client.get_token_ids() + self.assertGreater(len(token_ids), 100) + + kleros_tokens = kleros_client.get_token_info(token_ids[:5]) + self.assertEqual(len(kleros_tokens), 5) + for kleros_token in kleros_tokens: + self.assertTrue(fast_is_checksum_address(kleros_token.address)) + self.assertTrue(kleros_token.symbol_multihash.startswith("/ipfs/")) diff --git a/safe_price_service/tokens/tests/clients/test_zerion_client.py b/safe_price_service/tokens/tests/clients/test_zerion_client.py new file mode 100644 index 0000000..7633bcf --- /dev/null +++ b/safe_price_service/tokens/tests/clients/test_zerion_client.py @@ -0,0 +1,51 @@ +from django.test import TestCase + +from eth_account import Account + +from gnosis.eth import EthereumClient +from gnosis.eth.tests.utils import just_test_if_mainnet_node + +from ...clients.zerion_client import ( + UniswapComponent, + ZerionPoolMetadata, + ZerionUniswapV2TokenAdapterClient, +) + + +class TestZerionClient(TestCase): + def test_zerion_client(self): + mainnet_node = just_test_if_mainnet_node() + client = ZerionUniswapV2TokenAdapterClient(EthereumClient(mainnet_node)) + owl_pool_address = "0xBA6329EAe69707D6A0F273Bd082f4a0807A6B011" + + expected = [ + UniswapComponent( + address="0x1A5F9352Af8aF974bFC03399e3767DF6370d82e4", + tokenType="ERC20", + rate=0, + ), + UniswapComponent( + address="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + tokenType="ERC20", + rate=0, + ), + ] + components = client.get_components(owl_pool_address) + for component in components: + self.assertGreaterEqual(component.rate, 0) + component.rate = 0 + + self.assertEqual(components, expected) + + metadata = client.get_metadata(owl_pool_address) + expected = ZerionPoolMetadata( + address="0xBA6329EAe69707D6A0F273Bd082f4a0807A6B011", + name="OWL/USDC Pool", + symbol="UNI-V2", + decimals=18, + ) + self.assertEqual(metadata, expected) + + random_address = Account.create().address + self.assertIsNone(client.get_components(random_address)) + self.assertIsNone(client.get_metadata(random_address)) diff --git a/safe_price_service/tokens/tests/test_price_service.py b/safe_price_service/tokens/tests/test_price_service.py new file mode 100644 index 0000000..07017cf --- /dev/null +++ b/safe_price_service/tokens/tests/test_price_service.py @@ -0,0 +1,395 @@ +from unittest import mock +from unittest.mock import MagicMock + +from django.conf import settings +from django.test import TestCase +from django.utils import timezone + +from eth.constants import ZERO_ADDRESS +from eth_account import Account + +from gnosis.eth import EthereumClient, EthereumNetwork +from gnosis.eth.oracles import KyberOracle, OracleException, UnderlyingToken +from gnosis.eth.tests.utils import just_test_if_mainnet_node + +from ..clients import CannotGetPrice, CoingeckoClient, KrakenClient, KucoinClient +from ..services.price_service import ( + PriceService, + get_price_service, + get_price_services, + is_chain_supported, +) + + +class TestPriceService(TestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.ethereum_client = EthereumClient(settings.ETHEREUM_NODES_URLS[0]) + get_price_services.cache_clear() + + def setUp(self) -> None: + self.price_service = get_price_service(self.ethereum_client.get_chain_id()) + + def tearDown(self) -> None: + get_price_services.cache_clear() + + def test_get_price_services(self): + get_price_services.cache_clear() + with self.settings( + ETHEREUM_NODES_URLS=[ + self.ethereum_client.ethereum_node_url, + ] + ): + with mock.patch.object(EthereumClient, "get_chain_id", side_effect=IOError): + with self.assertLogs( + "safe_price_service.tokens.services.price_service" + ) as logger: + self.assertEqual(get_price_services(), {}) + self.assertEqual( + logger.output, + [ + f"ERROR:safe_price_service.tokens.services.price_service:Problem connecting to node-url={self.ethereum_client.ethereum_node_url}" + ], + ) + + get_price_services.cache_clear() + with self.settings( + ETHEREUM_NODES_URLS=[ + self.ethereum_client.ethereum_node_url, + self.ethereum_client.ethereum_node_url, + ] + ): + chain_id = self.ethereum_client.get_chain_id() + get_price_services.cache_clear() + self.assertEqual( + len(get_price_services()), 1 + ) # Same chainId, only one dict entry + self.assertTrue(is_chain_supported(chain_id)) + + get_price_services.cache_clear() + with self.settings( + ETHEREUM_NODES_URLS=["http://localhost:8545", "http://random-node:8545"] + ): + # Chain id is called thrice for every url, as cache is not mocked + chain_ids = [4815, 4815, 4815, 1623, 1623, 1623] + with mock.patch.object( + EthereumClient, "get_chain_id", side_effect=chain_ids + ) as get_chain_id_mock: + self.assertEqual( + len(get_price_services()), 2 + ) # Same chainId, only one dict entry + self.assertTrue(is_chain_supported(chain_ids[0])) + self.assertTrue(is_chain_supported(chain_ids[1])) + assert get_chain_id_mock.call_count == 6 + + def test_available_price_oracles(self): + # Ganache should have no oracle enabled + self.assertEqual(len(self.price_service.enabled_price_oracles), 0) + self.assertEqual(len(self.price_service.enabled_price_pool_oracles), 0) + self.assertEqual(len(self.price_service.enabled_composed_price_oracles), 0) + + def test_available_price_oracles_mainnet(self): + # Mainnet should have every oracle enabled + mainnet_node = just_test_if_mainnet_node() + price_service = PriceService(EthereumClient(mainnet_node)) + self.assertEqual(len(price_service.enabled_price_oracles), 6) + self.assertEqual(len(price_service.enabled_price_pool_oracles), 3) + self.assertEqual(len(price_service.enabled_composed_price_oracles), 4) + + @mock.patch.object(KrakenClient, "get_ether_usd_price", return_value=0.4) + @mock.patch.object(KucoinClient, "get_ether_usd_price", return_value=0.5) + def test_get_ether_usd_price(self, kucoin_mock: MagicMock, kraken_mock: MagicMock): + price_service = self.price_service + eth_usd_price = price_service.get_ether_usd_price() + self.assertEqual(eth_usd_price, kraken_mock.return_value) + kucoin_mock.assert_not_called() + + def test_get_native_coin_usd_price(self): + price_service = self.price_service + + # Unsupported network (Ganache) + with mock.patch.object( + KrakenClient, "get_ether_usd_price", return_value=1_600 + ) as kraken_mock: + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 1_600) + + # Test cache is working + kraken_mock.side_effect = CannotGetPrice + self.assertEqual(price_service.get_native_coin_usd_price(), 1_600) + + # Gnosis Chain + price_service.ethereum_network = EthereumNetwork.GNOSIS + with mock.patch.object(KrakenClient, "get_dai_usd_price", return_value=1.5): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 1.5) + + with mock.patch.object( + KrakenClient, "get_dai_usd_price", side_effect=CannotGetPrice + ): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 1) + + # POLYGON + price_service.ethereum_network = EthereumNetwork.POLYGON + with mock.patch.object(KrakenClient, "get_matic_usd_price", return_value=0.7): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 0.7) + + # EWT + price_service.ethereum_network = EthereumNetwork.ENERGY_WEB_CHAIN + with mock.patch.object(KrakenClient, "get_ewt_usd_price", return_value=0.9): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 0.9) + + # BINANCE + price_service.ethereum_network = EthereumNetwork.BINANCE_SMART_CHAIN_MAINNET + with mock.patch.object(KucoinClient, "get_bnb_usd_price", return_value=1.2): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 1.2) + + # Gather + price_service.ethereum_network = EthereumNetwork.GATHER_MAINNET_NETWORK + with mock.patch.object( + CoingeckoClient, "get_gather_usd_price", return_value=1.7 + ): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 1.7) + + # Avalanche + price_service.ethereum_network = EthereumNetwork.AVALANCHE_C_CHAIN + with mock.patch.object(KrakenClient, "get_avax_usd_price", return_value=6.5): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 6.5) + + # Aurora + price_service.ethereum_network = EthereumNetwork.AURORA_MAINNET + with mock.patch.object(KucoinClient, "get_aurora_usd_price", return_value=1.3): + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 1.3) + + # Cronos + with mock.patch.object(KucoinClient, "get_cro_usd_price", return_value=4.4): + price_service.ethereum_network = EthereumNetwork.CRONOS_MAINNET_BETA + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 4.4) + + # KuCoin + with mock.patch.object(KucoinClient, "get_kcs_usd_price", return_value=4.4): + price_service.ethereum_network = EthereumNetwork.KCC_MAINNET + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 4.4) + + # Milkomeda Cardano + with mock.patch.object(KrakenClient, "get_ada_usd_price", return_value=5.5): + price_service.ethereum_network = EthereumNetwork.MILKOMEDA_C1_MAINNET + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_native_coin_usd_price(), 5.5) + + # Milkomeda Algorand + with mock.patch.object(KrakenClient, "get_algo_usd_price", return_value=6.6): + price_service.ethereum_network = EthereumNetwork.MILKOMEDA_A1_MAINNET + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_algorand_usd_price(), 6.6) + + # XDC + with mock.patch.object(KucoinClient, "get_xdc_usd_price", return_value=7.7): + price_service.ethereum_network = EthereumNetwork.XINFIN_XDC_NETWORK + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_xdc_usd_price(), 7.7) + + price_service.ethereum_network = EthereumNetwork.XDC_APOTHEM_NETWORK + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_xdc_usd_price(), 7.7) + + # Meter + with mock.patch.object(CoingeckoClient, "get_mtr_usd_price", return_value=8.0): + price_service.ethereum_network = EthereumNetwork.METER_MAINNET + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_mtr_usd_price(), 8.0) + + price_service.ethereum_network = EthereumNetwork.METER_TESTNET + price_service.cache_native_coin_usd_price.clear() + self.assertEqual(price_service.get_mtr_usd_price(), 8.0) + + @mock.patch.object(CoingeckoClient, "get_bnb_usd_price", return_value=3.0) + @mock.patch.object(KucoinClient, "get_bnb_usd_price", return_value=5.0) + def test_get_binance_usd_price( + self, + get_bnb_usd_price_binance_mock: MagicMock, + get_bnb_usd_price_coingecko: MagicMock, + ): + price_service = self.price_service + + price = price_service.get_binance_usd_price() + self.assertEqual(price, 5.0) + + get_bnb_usd_price_binance_mock.side_effect = CannotGetPrice + price = price_service.get_binance_usd_price() + self.assertEqual(price, 3.0) + + @mock.patch.object(CoingeckoClient, "get_ewt_usd_price", return_value=3.0) + @mock.patch.object(KucoinClient, "get_ewt_usd_price", return_value=7.0) + @mock.patch.object(KrakenClient, "get_ewt_usd_price", return_value=5.0) + def test_get_ewt_usd_price( + self, + get_ewt_usd_price_kraken_mock: MagicMock, + get_ewt_usd_price_kucoin_mock: MagicMock, + get_ewt_usd_price_coingecko_mock: MagicMock, + ): + price_service = self.price_service + + price = price_service.get_ewt_usd_price() + self.assertEqual(price, 5.0) + + get_ewt_usd_price_kraken_mock.side_effect = CannotGetPrice + price = price_service.get_ewt_usd_price() + self.assertEqual(price, 7.0) + + get_ewt_usd_price_kucoin_mock.side_effect = CannotGetPrice + price = price_service.get_ewt_usd_price() + self.assertEqual(price, 3.0) + + @mock.patch.object(CoingeckoClient, "get_matic_usd_price", return_value=3.0) + @mock.patch.object(KucoinClient, "get_matic_usd_price", return_value=7.0) + @mock.patch.object(KrakenClient, "get_matic_usd_price", return_value=5.0) + def test_get_matic_usd_price( + self, + get_matic_usd_price_kraken_mock: MagicMock, + get_matic_usd_price_binance_mock: MagicMock, + get_matic_usd_price_coingecko_mock: MagicMock, + ): + price_service = self.price_service + + price = price_service.get_matic_usd_price() + self.assertEqual(price, 5.0) + + get_matic_usd_price_kraken_mock.side_effect = CannotGetPrice + price = price_service.get_matic_usd_price() + self.assertEqual(price, 7.0) + + get_matic_usd_price_binance_mock.side_effect = CannotGetPrice + price = price_service.get_matic_usd_price() + self.assertEqual(price, 3.0) + + def test_get_token_eth_value_from_oracles(self): + mainnet_node = just_test_if_mainnet_node() + price_service = PriceService(EthereumClient(mainnet_node)) + gno_token_address = "0x6810e776880C02933D47DB1b9fc05908e5386b96" + token_eth_value = price_service.get_token_eth_value_from_oracles( + gno_token_address + ) + self.assertIsInstance(token_eth_value, float) + self.assertGreater(token_eth_value, 0) + + @mock.patch.object(KyberOracle, "get_price", return_value=1.23, autospec=True) + def test_get_token_eth_value_mocked(self, kyber_get_price_mock: MagicMock): + price_service = self.price_service + oracle_1 = mock.MagicMock() + oracle_1.get_price.return_value = 1.23 + oracle_2 = mock.MagicMock() + oracle_3 = mock.MagicMock() + price_service.enabled_price_oracles = (oracle_1, oracle_2, oracle_3) + self.assertEqual(len(price_service.enabled_price_oracles), 3) + random_address = Account.create().address + self.assertEqual(len(price_service.cache_token_eth_value), 0) + + self.assertEqual( + price_service.get_token_eth_value_from_oracles(random_address), 1.23 + ) + self.assertEqual(price_service.cache_token_eth_value[(random_address,)], 1.23) + + # Make every oracle fail + oracle_1.get_price.side_effect = OracleException + oracle_2.get_price.side_effect = OracleException + oracle_3.get_price.side_effect = OracleException + + # Check cache + self.assertEqual( + price_service.get_token_eth_value_from_oracles(random_address), 1.23 + ) + random_address_2 = Account.create().address + self.assertEqual( + price_service.get_token_eth_value_from_oracles(random_address_2), 0.0 + ) + self.assertEqual(price_service.cache_token_eth_value[(random_address,)], 1.23) + self.assertEqual(price_service.cache_token_eth_value[(random_address_2,)], 0.0) + + @mock.patch.object( + PriceService, "get_underlying_tokens", return_value=[], autospec=True + ) + @mock.patch.object( + PriceService, + "get_token_eth_value_from_oracles", + autospec=True, + return_value=1.0, + ) + def test_get_token_eth_value_from_composed_oracles( + self, get_token_eth_value_mock: MagicMock, price_service_mock: MagicMock + ): + price_service = self.price_service + token_one = UnderlyingToken("0x48f07301E9E29c3C38a80ae8d9ae771F224f1054", 0.482) + token_two = UnderlyingToken("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 0.376) + token_three = UnderlyingToken("0xA0b86991c6218b36c1d19D4a2e9Eb0cE360", 0.142) + price_service_mock.return_value = [token_one, token_two, token_three] + curve_price = "0xe7ce624c00381b4b7abb03e633fb4acac4537dd6" + eth_price = price_service.get_token_eth_value_from_composed_oracles(curve_price) + self.assertEqual(eth_price, 1.0) + + def test_get_token_usd_price(self): + mainnet_node = just_test_if_mainnet_node() + price_service = PriceService(EthereumClient(mainnet_node)) + gno_token_address = "0x6810e776880C02933D47DB1b9fc05908e5386b96" + token_fiat_price_with_timestamp = price_service.get_token_usd_price( + gno_token_address + ) + self.assertIsInstance(token_fiat_price_with_timestamp.fiat_price, float) + self.assertGreater(token_fiat_price_with_timestamp.fiat_price, 0) + self.assertLessEqual(token_fiat_price_with_timestamp.timestamp, timezone.now()) + + with mock.patch.object( + PriceService, + "get_token_eth_value_from_oracles", + autospec=True, + return_value=0, + ): + with mock.patch.object( + PriceService, + "get_token_eth_value_from_composed_oracles", + autospec=True, + return_value=0, + ): + # Response should be cached + self.assertEqual( + price_service.get_token_usd_price(gno_token_address), + token_fiat_price_with_timestamp, + ) + + # Clear cache, only available oracle should be coingecko + price_service.cache_token_usd_price.clear() + + token_fiat_price_with_timestamp_from_coingecko = ( + price_service.get_token_usd_price(gno_token_address) + ) + self.assertNotEqual( + token_fiat_price_with_timestamp_from_coingecko, + token_fiat_price_with_timestamp, + ) + self.assertAlmostEqual( + token_fiat_price_with_timestamp.fiat_price, + token_fiat_price_with_timestamp_from_coingecko.fiat_price, + delta=10.0, + ) + + def test_get_token_usd_price_native_coin(self): + with mock.patch.object( + PriceService, + "get_native_coin_usd_price", + autospec=True, + return_value=2342, + ) as get_native_coin_usd_price_mock: + self.assertEqual( + self.price_service.get_token_usd_price(ZERO_ADDRESS).fiat_price, + get_native_coin_usd_price_mock.return_value, + ) diff --git a/safe_price_service/tokens/tests/test_views.py b/safe_price_service/tokens/tests/test_views.py new file mode 100644 index 0000000..87bad2a --- /dev/null +++ b/safe_price_service/tokens/tests/test_views.py @@ -0,0 +1,171 @@ +import logging +from unittest import mock +from unittest.mock import MagicMock + +from django.urls import reverse +from django.utils import timezone + +from eth_account import Account +from rest_framework import status +from rest_framework.test import APITestCase + +from ..clients import CannotGetPrice +from ..services.price_service import PriceService + +logger = logging.getLogger(__name__) + + +class TestTokenViews(APITestCase): + ganache_chain_id = 1337 + + @mock.patch.object(timezone, "now", return_value=timezone.now()) + def test_token_price_view(self, timezone_now_mock: MagicMock): + chain_id = 1 + invalid_address = "0x1234" + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + invalid_address, + ), + ) + ) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertEqual( + response.json(), + {"arguments": [chain_id], "code": 2, "message": "Chain is not supported"}, + ) + + chain_id = self.ganache_chain_id + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + invalid_address, + ), + ) + ) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertEqual( + response.json(), + { + "arguments": [invalid_address], + "code": 1, + "message": "Invalid ethereum address", + }, + ) + + valid_address = Account.create().address + with mock.patch.object( + PriceService, + "get_token_eth_value_from_oracles", + return_value=4815, + autospec=True, + ) as get_token_eth_value_from_oracles_mock: + with mock.patch.object( + PriceService, "get_native_coin_usd_price", return_value=3, autospec=True + ) as get_native_coin_usd_price_mock: + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + valid_address, + ), + ) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "fiat_code": "USD", + "fiat_price": str( + get_token_eth_value_from_oracles_mock.return_value + * get_native_coin_usd_price_mock.return_value + ), + "timestamp": timezone_now_mock.return_value.isoformat().replace( + "+00:00", "Z" + ), + }, + ) + + @mock.patch.object( + PriceService, "get_native_coin_usd_price", return_value=321.2, autospec=True + ) + def test_token_price_view_address_0( + self, get_native_coin_usd_price_mock: MagicMock + ): + chain_id = 1 + token_address = "0x0000000000000000000000000000000000000000" + + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + token_address, + ), + ) + ) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertEqual( + response.json(), + {"arguments": [chain_id], "code": 2, "message": "Chain is not supported"}, + ) + + chain_id = self.ganache_chain_id + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + token_address, + ), + ) + ) + + # Native token should be retrieved even if it is not part of the Token table + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["fiat_code"], "USD") + self.assertEqual(response.data["fiat_price"], "321.2") + self.assertTrue(response.data["timestamp"]) + + @mock.patch.object( + PriceService, + "get_token_usd_price", + side_effect=CannotGetPrice(), + ) + def test_token_price_view_error(self, get_token_usd_price_mock: MagicMock): + chain_id = 1 + token_address = "0x0000000000000000000000000000000000000000" + + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + token_address, + ), + ) + ) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertEqual( + response.json(), + {"arguments": [chain_id], "code": 2, "message": "Chain is not supported"}, + ) + + chain_id = self.ganache_chain_id + response = self.client.get( + reverse( + "v1:tokens:price-usd", + args=( + chain_id, + token_address, + ), + ) + ) + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(response.data["message"], "Price retrieval failed") + self.assertEqual(response.data["arguments"], [token_address]) diff --git a/safe_price_service/tokens/urls.py b/safe_price_service/tokens/urls.py new file mode 100644 index 0000000..2291725 --- /dev/null +++ b/safe_price_service/tokens/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +app_name = "tokens" + +urlpatterns = [ + path( + "/tokens//prices/usd/", + views.TokenPriceView.as_view(), + name="price-usd", + ), +] diff --git a/safe_price_service/tokens/views.py b/safe_price_service/tokens/views.py new file mode 100644 index 0000000..de3c85f --- /dev/null +++ b/safe_price_service/tokens/views.py @@ -0,0 +1,58 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +from rest_framework import response, status +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response + +from gnosis.eth.utils import fast_is_checksum_address + +from . import serializers +from .clients import CannotGetPrice +from .services.price_service import get_price_service, is_chain_supported + + +class TokenPriceView(GenericAPIView): + serializer_class = serializers.TokenPriceResponseSerializer + lookup_field = "address" + + @method_decorator(cache_page(60 * 10)) # Cache 10 minutes + def get(self, request, *args, **kwargs): + chain_id = self.kwargs["chain_id"] + address = self.kwargs["address"] + + if not is_chain_supported(chain_id): + return response.Response( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data={ + "code": 2, + "message": "Chain is not supported", + "arguments": [chain_id], + }, + ) + + if not fast_is_checksum_address(address): + return response.Response( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data={ + "code": 1, + "message": "Invalid ethereum address", + "arguments": [address], + }, + ) + + try: + price_service = get_price_service(chain_id) + data = price_service.get_token_usd_price(address) + serializer = self.get_serializer(data) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + except CannotGetPrice: + return Response( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + data={ + "code": 10, + "message": "Price retrieval failed", + "arguments": [address], + }, + ) diff --git a/safe_price_service/utils/__init__.py b/safe_price_service/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_price_service/utils/exceptions.py b/safe_price_service/utils/exceptions.py new file mode 100644 index 0000000..06b8875 --- /dev/null +++ b/safe_price_service/utils/exceptions.py @@ -0,0 +1,39 @@ +import logging + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import exception_handler + +logger = logging.getLogger(__name__) + + +def custom_exception_handler(exc, context): + if isinstance(exc, NodeConnectionException): + response = Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + + if str(exc): + exception_str = "{}: {}".format(exc.__class__.__name__, exc) + else: + exception_str = exc.__class__.__name__ + response.data = { + "exception": "Problem connecting to Ethereum network", + "trace": exception_str, + } + + logger.warning( + "%s - Exception: %s - Data received %s", + context["request"].build_absolute_uri(), + exception_str, + context["request"].data, + exc_info=exc, + ) + else: + # Call REST framework's default exception handler, + # to get the standard error response. + response = exception_handler(exc, context) + + return response + + +class NodeConnectionException(IOError): + pass diff --git a/safe_price_service/utils/loggers.py b/safe_price_service/utils/loggers.py new file mode 100644 index 0000000..c1c8831 --- /dev/null +++ b/safe_price_service/utils/loggers.py @@ -0,0 +1,64 @@ +import logging +import time + +from django.http import HttpRequest + +from gunicorn import glogging + + +def get_milliseconds_now(): + return int(time.time() * 1000) + + +class IgnoreCheckUrl(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + return not ("GET /check/" in message and "200" in message) + + +class IgnoreSucceededNone(logging.Filter): + """ + Ignore Celery messages like: + ``` + Task safe_price_service.history.tasks.index_internal_txs_task[89ad3c46-aeb3-48a1-bd6f-2f3684323ca8] + succeeded in 1.0970600529108196s: None + ``` + They are usually emitted when a redis lock is active + """ + + def filter(self, rec: logging.LogRecord): + message = rec.getMessage() + return not ("Task" in message and "succeeded" in message and "None" in message) + + +class CustomGunicornLogger(glogging.Logger): + def setup(self, cfg): + super().setup(cfg) + + # Add filters to Gunicorn logger + logger = logging.getLogger("gunicorn.access") + logger.addFilter(IgnoreCheckUrl()) + + +class LoggingMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.logger = logging.getLogger("LoggingMiddleware") + + def __call__(self, request: HttpRequest): + milliseconds = get_milliseconds_now() + response = self.get_response(request) + if request.resolver_match: + route = ( + request.resolver_match.route if request.resolver_match else request.path + ) + delta = get_milliseconds_now() - milliseconds + self.logger.info( + "MT::%s::%s::%s::%d::%s", + request.method, + route, + delta, + response.status_code, + request.path, + ) + return response diff --git a/setup.cfg b/setup.cfg index ce24b79..fa4b7c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,7 +22,7 @@ env = DJANGO_DOT_ENV_FILE=.env.test [mypy] -python_version = 3.10 +python_version = 3.11 check_untyped_defs = True ignore_missing_imports = True warn_unused_ignores = True