Skip to content

Commit

Permalink
Code restructuring (#50)
Browse files Browse the repository at this point in the history
Full refactoring of the code.

- There are multiple classes for fetching data. For now two such
classes, `Web3API` and `OrderbookAPI` are implemented.
- Tests are subclasses of `BaseTest`. They have 
- Tests and API use common data types. For now, `Trades`, `OrderData`,
`OrderExecution` are implemented.
  • Loading branch information
fhenneke authored Jun 30, 2023
1 parent b87631a commit 8be84b7
Show file tree
Hide file tree
Showing 22 changed files with 1,009 additions and 1,174 deletions.
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM python:3.11
COPY requirements.txt .
RUN python -m pip install -r requirements.txt
COPY . .
RUN pip install -r requirements.txt
CMD python3.11 -m src.daemon
CMD python -m src.daemon
File renamed without changes.
125 changes: 125 additions & 0 deletions src/apis/orderbookapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
OrderbookAPI for fetching relevant data using the CoW Swap Orderbook API.
"""
# pylint: disable=logging-fstring-interpolation

from typing import Any, Optional
import json
import requests
from src.helper_functions import get_logger
from src.models import Trade, OrderExecution
from src.constants import (
header,
REQUEST_TIMEOUT,
SUCCESS_CODE,
FAIL_CODE,
)

PROD_BASE_URL = "https://api.cow.fi/mainnet/api/v1/"
BARN_BASE_URL = "https://barn.api.cow.fi/mainnet/api/v1/"


class OrderbookAPI:
"""
Class for fetching data from a Web3 API.
"""

def __init__(self):
self.logger = get_logger()

def get_solver_competition_data(self, tx_hash: str) -> Optional[dict[str, Any]]:
"""
Get solver competition data from a transaction hash.
The returned dict follows the schema outlined here:
https://api.cow.fi/docs/#/default/get_api_v1_solver_competition_by_tx_hash__tx_hash_
"""
prod_endpoint_url = f"{PROD_BASE_URL}solver_competition/by_tx_hash/{tx_hash}"
barn_endpoint_url = f"{BARN_BASE_URL}solver_competition/by_tx_hash/{tx_hash}"
try:
json_competition_data = requests.get(
prod_endpoint_url,
headers=header,
timeout=REQUEST_TIMEOUT,
)
if json_competition_data.status_code == SUCCESS_CODE:
solver_competition_data = json.loads(json_competition_data.text)
elif json_competition_data.status_code == FAIL_CODE:
barn_competition_data = requests.get(
barn_endpoint_url, headers=header, timeout=REQUEST_TIMEOUT
)
if barn_competition_data.status_code == SUCCESS_CODE:
solver_competition_data = json.loads(barn_competition_data.text)
else:
return None
except requests.RequestException as err:
self.logger.warning(
f"Connection error while fetching competition data. Hash: {tx_hash}, error: {err}"
)
return None
return solver_competition_data

def get_quote(self, trade: Trade) -> Optional[Trade]:
"""
Given a trade, compute buy_amount, sell_amount, and fee_amount of the trade
as proposed by our quoting infrastructure.
"""

if trade.data.is_sell_order:
kind = "sell"
limit_amount_name = "sellAmountBeforeFee"
executed_amount = trade.execution.sell_amount
else:
kind = "buy"
limit_amount_name = "buyAmountAfterFee"
executed_amount = trade.execution.buy_amount

request_dict = {
"sellToken": trade.data.sell_token,
"buyToken": trade.data.buy_token,
"receiver": "0x0000000000000000000000000000000000000000",
"appData": "0x0000000000000000000000000000000000000000000000000000000000000000",
"partiallyFillable": False,
"sellTokenBalance": "erc20",
"buyTokenBalance": "erc20",
"from": "0x0000000000000000000000000000000000000000",
"priceQuality": "optimal",
"signingScheme": "eip712",
"onchainOrder": False,
"kind": kind,
limit_amount_name: str(executed_amount),
}
prod_endpoint_url = f"{PROD_BASE_URL}quote"

try:
quote_response = requests.post(
prod_endpoint_url,
headers=header,
json=request_dict,
timeout=REQUEST_TIMEOUT,
)
except requests.RequestException as err:
self.logger.warning(
f"Fee quote failed. Request: {request_dict}, error: {err}"
)
return None

if quote_response.status_code != SUCCESS_CODE:
error_response_json = json.loads(quote_response.content)
self.logger.warning(
f"Error {error_response_json['errorType']},"
+ f"{error_response_json['description']} while getting quote for trade {trade}"
)
return None

quote_json = json.loads(quote_response.text)
self.logger.debug("Quote received: %s", quote_json)

quote_buy_amount = int(quote_json["quote"]["buyAmount"])
quote_sell_amount = int(quote_json["quote"]["sellAmount"])
quote_fee_amount = int(quote_json["quote"]["feeAmount"])

quote_execution = OrderExecution(
quote_buy_amount, quote_sell_amount, quote_fee_amount
)

return Trade(trade.data, quote_execution)
215 changes: 215 additions & 0 deletions src/apis/web3api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""
Web3API for fetching relevant data using the web3 library.
"""
# pylint: disable=logging-fstring-interpolation

from os import getenv
from typing import Any, Optional
from fractions import Fraction
from dotenv import load_dotenv
from web3 import Web3
from web3.types import TxData, TxReceipt
from eth_typing import Address, HexStr
from hexbytes import HexBytes
from contracts.gpv2_settlement import gpv2_settlement
from src.models import Trade, OrderData, OrderExecution
from src.helper_functions import get_logger
from src.constants import SETTLEMENT_CONTRACT_ADDRESS


class Web3API:
"""
Class for fetching data from a Web3 API.
"""

def __init__(self):
load_dotenv()
infura_key = getenv("INFURA_KEY")
self.url = f"https://mainnet.infura.io/v3/{infura_key}"
self.web_3 = Web3(Web3.HTTPProvider(self.url))
self.contract = self.web_3.eth.contract(
address=Address(HexBytes(SETTLEMENT_CONTRACT_ADDRESS)), abi=gpv2_settlement
)
self.logger = get_logger()

def get_current_block_number(self) -> Optional[int]:
"""
Function that returns the current block number
"""
try:
return int(self.web_3.eth.block_number)
except ValueError as err:
self.logger.warning(f"Error while fetching block number: {err}")
return None

def get_tx_hashes_by_block(
self, start_block: int, end_block: int
) -> Optional[list[str]]:
"""
Function filters hashes by contract address, and block ranges
"""
filter_criteria = {
"fromBlock": int(start_block),
"toBlock": int(end_block),
"address": SETTLEMENT_CONTRACT_ADDRESS,
"topics": [
"0xa07a543ab8a018198e99ca0184c93fe9050a79400a0a723441f84de1d972cc17"
# "0x40338ce1a7c49204f0099533b1e9a7ee0a3d261f84974ab7af36105b8c4e9db4"
],
}

try:
log_receipts = self.web_3.eth.filter(filter_criteria).get_all_entries() # type: ignore
except ValueError as err:
self.logger.warning(f"ValueError while fetching hashes: {err}")
return None

settlement_hashes_list = list(
{log_receipt["transactionHash"].hex() for log_receipt in log_receipts}
)

return settlement_hashes_list

def get_transaction(self, tx_hash: str) -> Optional[TxData]:
"""
Takes settlement hash as input, returns transaction data.
"""
try:
transaction = self.web_3.eth.get_transaction(HexStr(tx_hash))
except ValueError as err:
self.logger.warning(f"Error while fetching transaction: {err}")
transaction = None

return transaction

def get_receipt(self, tx_hash: str) -> Optional[TxReceipt]:
"""
Get the receipt of a transaction from the transaction hash.
This is used to obtain the gas used for the transaction.
"""
try:
receipt = self.web_3.eth.wait_for_transaction_receipt(HexStr(tx_hash))
except ValueError as err:
self.logger.warning(f"Error fetching log receipt: {err}")
receipt = None
return receipt

def get_settlement(self, transaction: TxData) -> dict[str, Any]:
"""
Decode settlement from transaction using the settlement contract.
"""
return self.get_settlement_from_calldata(transaction["input"])

def get_settlement_from_calldata(self, calldata: str) -> dict[str, Any]:
"""
Decode settlement from transaction using the settlement contract.
"""
return self.contract.decode_function_input(calldata)[1]

def get_trades(self, settlement: dict[str, Any]) -> list[Trade]:
"""
Get all trades from a settlement.
"""
trades = []
for i in range(len(settlement["trades"])):
data = self.get_order_data_from_settlement(settlement, i)
execution = self.get_order_execution_from_settlement(settlement, i)
trades.append(Trade(data, execution))

return trades

def get_order_data_from_settlement(
self, settlement: dict[str, Any], i: int
) -> OrderData:
"""
Given a settlement and the index of an trade, return order information.
"""
decoded_trade = settlement["trades"][i]
tokens = settlement["tokens"]

order_data = OrderData(
decoded_trade["buyAmount"],
decoded_trade["sellAmount"],
decoded_trade["feeAmount"],
tokens[decoded_trade["buyTokenIndex"]],
tokens[decoded_trade["sellTokenIndex"]],
self.is_sell_order(decoded_trade),
self.is_partially_fillable(decoded_trade),
)
return order_data

def get_order_execution_from_settlement(
self, settlement: dict[str, Any], i: int
) -> OrderExecution:
# pylint: disable=too-many-locals
"""
Given a settlement and the index of a trade, compute the execution of the order.
"""
decoded_trade = settlement["trades"][i]
tokens = settlement["tokens"]
clearing_prices = settlement["clearingPrices"]

buy_token = tokens[decoded_trade["buyTokenIndex"]]
buy_token_price = clearing_prices[decoded_trade["buyTokenIndex"]]
buy_token_index_ucp = tokens.index(buy_token)
buy_token_price_ucp = clearing_prices[buy_token_index_ucp]

sell_token = tokens[decoded_trade["sellTokenIndex"]]
sell_token_price = clearing_prices[decoded_trade["sellTokenIndex"]]
sell_token_index_ucp = tokens.index(sell_token)
sell_token_price_ucp = clearing_prices[sell_token_index_ucp]

executed_amount = decoded_trade["executedAmount"]
precomputed_fee_amount = decoded_trade["feeAmount"]

if self.is_sell_order(decoded_trade): # sell order
buy_amount = int(
executed_amount * Fraction(sell_token_price, buy_token_price)
)
sell_amount = int(
buy_amount * Fraction(buy_token_price_ucp, sell_token_price_ucp)
)
fee_amount = precomputed_fee_amount + executed_amount - sell_amount
else: # buy order
buy_amount = executed_amount
sell_amount = int(
buy_amount * Fraction(buy_token_price_ucp, sell_token_price_ucp)
)
fee_amount = (
precomputed_fee_amount
+ int(buy_amount * Fraction(buy_token_price, sell_token_price))
- sell_amount
)

return OrderExecution(buy_amount, sell_amount, fee_amount)

def is_sell_order(self, decoded_trade):
"""
Check if the order corresponding to a trade is a sell order.
"""
return str(f"{decoded_trade['flags']:08b}")[-1] == "0"

def is_partially_fillable(self, decoded_trade):
"""
Check if the order corresponding to a trade is partially-fillable.
"""
return str(f"{decoded_trade['flags']:08b}")[-2] == "1"

def get_batch_gas_costs(
self, transaction: TxData, receipt: TxReceipt
) -> tuple[int, int]:
"""
Combine the transaction and receipt to return gas used and gas price.
"""
return int(receipt["gasUsed"]), int(transaction["gasPrice"])

def get_current_gas_price(self) -> Optional[int]:
"""
Get the current gas price.
"""
try:
gas_price = int(self.web_3.eth.gas_price)
except ValueError as err:
self.logger.warning(f"Error fetching gas price: {err}")
gas_price = None
return gas_price
21 changes: 17 additions & 4 deletions src/constants.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
"""
All Constants that are used throughout the project
"""
ADDRESS = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41"

# main loop
SLEEP_TIME_IN_SEC = 10

# surplus tests
ABSOLUTE_ETH_FLAG_AMOUNT = 0.002
REL_DEVIATION_FLAG_PERCENT = 0.1
FEE_ABSOLUTE_DEVIATION_ETH_FLAG = 0.002
FEE_RELATIVE_DEVIATION_FLAG = 0.20
SLEEP_TIME_IN_SEC = 60

# cost coverage test
COST_COVERAGE_ABSOLUTE_DEVIATION_ETH = 0.005
COST_COVERAGE_RELATIVE_DEVIATION = 0.20

# fee quote test
FEE_ABSOLUTE_DEVIATION_ETH_FLAG = 0.01
FEE_RELATIVE_DEVIATION_FLAG = 0.50

# requests
SETTLEMENT_CONTRACT_ADDRESS = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41"
REQUEST_TIMEOUT = 5
SUCCESS_CODE = 200
FAIL_CODE = 404

Expand Down
Loading

0 comments on commit 8be84b7

Please sign in to comment.