diff --git a/contracts/gpv2_settlement_abi.py b/contracts/gpv2_settlement_abi.py new file mode 100644 index 0000000..c64e82a --- /dev/null +++ b/contracts/gpv2_settlement_abi.py @@ -0,0 +1,389 @@ +"""Contract ABI of the CoW Protocol settlement contract +""" + +gpv2_settlement_abi = [ + { + "inputs": [ + { + "internalType": "contract GPv2Authentication", + "name": "authenticator_", + "type": "address", + }, + {"internalType": "contract IVault", "name": "vault_", "type": "address"}, + ], + "stateMutability": "nonpayable", + "type": "constructor", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "address", + "name": "target", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "value", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "bytes4", + "name": "selector", + "type": "bytes4", + }, + ], + "name": "Interaction", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "address", + "name": "owner", + "type": "address", + }, + { + "indexed": False, + "internalType": "bytes", + "name": "orderUid", + "type": "bytes", + }, + ], + "name": "OrderInvalidated", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "address", + "name": "owner", + "type": "address", + }, + { + "indexed": False, + "internalType": "bytes", + "name": "orderUid", + "type": "bytes", + }, + { + "indexed": False, + "internalType": "bool", + "name": "signed", + "type": "bool", + }, + ], + "name": "PreSignature", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "address", + "name": "solver", + "type": "address", + } + ], + "name": "Settlement", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "address", + "name": "owner", + "type": "address", + }, + { + "indexed": False, + "internalType": "contract IERC20", + "name": "sellToken", + "type": "address", + }, + { + "indexed": False, + "internalType": "contract IERC20", + "name": "buyToken", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "buyAmount", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "feeAmount", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "bytes", + "name": "orderUid", + "type": "bytes", + }, + ], + "name": "Trade", + "type": "event", + }, + { + "inputs": [], + "name": "authenticator", + "outputs": [ + { + "internalType": "contract GPv2Authentication", + "name": "", + "type": "address", + } + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "domainSeparator", + "outputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes", "name": "", "type": "bytes"}], + "name": "filledAmount", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes[]", "name": "orderUids", "type": "bytes[]"}], + "name": "freeFilledAmountStorage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes[]", "name": "orderUids", "type": "bytes[]"}], + "name": "freePreSignatureStorage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "offset", "type": "uint256"}, + {"internalType": "uint256", "name": "length", "type": "uint256"}, + ], + "name": "getStorageAt", + "outputs": [{"internalType": "bytes", "name": "", "type": "bytes"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes", "name": "orderUid", "type": "bytes"}], + "name": "invalidateOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes", "name": "", "type": "bytes"}], + "name": "preSignature", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes", "name": "orderUid", "type": "bytes"}, + {"internalType": "bool", "name": "signed", "type": "bool"}, + ], + "name": "setPreSignature", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]", + }, + { + "internalType": "uint256[]", + "name": "clearingPrices", + "type": "uint256[]", + }, + { + "components": [ + { + "internalType": "uint256", + "name": "sellTokenIndex", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "buyTokenIndex", + "type": "uint256", + }, + {"internalType": "address", "name": "receiver", "type": "address"}, + { + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256", + }, + {"internalType": "uint256", "name": "buyAmount", "type": "uint256"}, + {"internalType": "uint32", "name": "validTo", "type": "uint32"}, + {"internalType": "bytes32", "name": "appData", "type": "bytes32"}, + {"internalType": "uint256", "name": "feeAmount", "type": "uint256"}, + {"internalType": "uint256", "name": "flags", "type": "uint256"}, + { + "internalType": "uint256", + "name": "executedAmount", + "type": "uint256", + }, + {"internalType": "bytes", "name": "signature", "type": "bytes"}, + ], + "internalType": "struct GPv2Trade.Data[]", + "name": "trades", + "type": "tuple[]", + }, + { + "components": [ + {"internalType": "address", "name": "target", "type": "address"}, + {"internalType": "uint256", "name": "value", "type": "uint256"}, + {"internalType": "bytes", "name": "callData", "type": "bytes"}, + ], + "internalType": "struct GPv2Interaction.Data[][3]", + "name": "interactions", + "type": "tuple[][3]", + }, + ], + "name": "settle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "targetContract", "type": "address"}, + {"internalType": "bytes", "name": "calldataPayload", "type": "bytes"}, + ], + "name": "simulateDelegatecall", + "outputs": [{"internalType": "bytes", "name": "response", "type": "bytes"}], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "targetContract", "type": "address"}, + {"internalType": "bytes", "name": "calldataPayload", "type": "bytes"}, + ], + "name": "simulateDelegatecallInternal", + "outputs": [{"internalType": "bytes", "name": "response", "type": "bytes"}], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + { + "components": [ + {"internalType": "bytes32", "name": "poolId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "assetInIndex", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "assetOutIndex", + "type": "uint256", + }, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + {"internalType": "bytes", "name": "userData", "type": "bytes"}, + ], + "internalType": "struct IVault.BatchSwapStep[]", + "name": "swaps", + "type": "tuple[]", + }, + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]", + }, + { + "components": [ + { + "internalType": "uint256", + "name": "sellTokenIndex", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "buyTokenIndex", + "type": "uint256", + }, + {"internalType": "address", "name": "receiver", "type": "address"}, + { + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256", + }, + {"internalType": "uint256", "name": "buyAmount", "type": "uint256"}, + {"internalType": "uint32", "name": "validTo", "type": "uint32"}, + {"internalType": "bytes32", "name": "appData", "type": "bytes32"}, + {"internalType": "uint256", "name": "feeAmount", "type": "uint256"}, + {"internalType": "uint256", "name": "flags", "type": "uint256"}, + { + "internalType": "uint256", + "name": "executedAmount", + "type": "uint256", + }, + {"internalType": "bytes", "name": "signature", "type": "bytes"}, + ], + "internalType": "struct GPv2Trade.Data", + "name": "trade", + "type": "tuple", + }, + ], + "name": "swap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "vault", + "outputs": [{"internalType": "contract IVault", "name": "", "type": "address"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "vaultRelayer", + "outputs": [ + {"internalType": "contract GPv2VaultRelayer", "name": "", "type": "address"} + ], + "stateMutability": "view", + "type": "function", + }, + {"stateMutability": "payable", "type": "receive"}, +] diff --git a/src/constants.py b/src/constants.py index f8daec5..3eeafec 100644 --- a/src/constants.py +++ b/src/constants.py @@ -14,6 +14,8 @@ "0x83F20F44975D03b1b09e64809B757c47f942BEeA" ) +REQUEST_TIMEOUT = 5 + # Time limit, currently set to 1 full day, after which Coingecko Token List is re-fetched (in seconds) COINGECKO_TOKEN_LIST_RELOAD_TIME = 86400 diff --git a/src/fees/__init__.py b/src/fees/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fees/compute_fees.py b/src/fees/compute_fees.py new file mode 100644 index 0000000..3a8de0e --- /dev/null +++ b/src/fees/compute_fees.py @@ -0,0 +1,475 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from dataclasses import dataclass +from fractions import Fraction +import math +import os +from typing import Any +from dotenv import load_dotenv +from eth_typing import Address +from hexbytes import HexBytes + +from src.constants import ( + REQUEST_TIMEOUT, +) +import requests + +# types for trades + + +@dataclass +class Trade: + """Class for""" + + order_uid: HexBytes + sell_amount: int + buy_amount: int + sell_token: HexBytes + buy_token: HexBytes + limit_sell_amount: int + limit_buy_amount: int + kind: str + sell_token_clearing_price: int + buy_token_clearing_price: int + fee_policies: list["FeePolicy"] + + def volume(self) -> int: + """Compute volume of a trade in the surplus token""" + if self.kind == "sell": + return self.buy_amount + if self.kind == "buy": + return self.sell_amount + raise ValueError(f"Order kind {self.kind} is invalid.") + + def surplus(self) -> int: + """Compute surplus of a trade in the surplus token + For partially fillable orders, rounding is such that the reference for computing surplus is + such that it gives the worst price still allowed by the smart contract. That means that for + sell orders the limit buy amount is rounded up and for buy orders the limit sell amount is + rounded down. + """ + if self.kind == "sell": + current_limit_buy_amount = math.ceil( + self.limit_buy_amount + * Fraction(self.sell_amount, self.limit_sell_amount) + ) + return self.buy_amount - current_limit_buy_amount + if self.kind == "buy": + current_limit_sell_amount = int( + self.limit_sell_amount + * Fraction(self.buy_amount, self.limit_buy_amount) + ) + return current_limit_sell_amount - self.sell_amount + raise ValueError(f"Order kind {self.kind} is invalid.") + + def raw_surplus(self) -> int: + """Compute raw surplus of a trade in the surplus token + First, the application of protocol fees is reversed. Then, surplus of the resulting trade + is computed.""" + raw_trade = deepcopy(self) + for fee_policy in reversed(self.fee_policies): + raw_trade = fee_policy.reverse_protocol_fee(raw_trade) + return raw_trade.surplus() + + def protocol_fee(self): + """Compute protocol fees of a trade in the surplus token + Protocol fees are computed as the difference of raw surplus and surplus.""" + + return self.raw_surplus() - self.surplus() + + def surplus_token(self) -> HexBytes: + """Returns the surplus token""" + if self.kind == "sell": + return self.buy_token + if self.kind == "buy": + return self.sell_token + raise ValueError(f"Order kind {self.kind} is invalid.") + + def price_improvement(self, quote: "Quote") -> int: + """Compute price improvement + For partially fillable orders, rounding is such that the reference for computing price + improvement is as if the quote would determine the limit price. That means that for sell + orders the quote buy amount is rounded up and for buy orders the quote sell amount is + rounded down. + """ + effective_sell_amount = quote.effective_sell_amount(self.kind) + effective_buy_amount = quote.effective_buy_amount(self.kind) + if self.kind == "sell": + current_limit_quote_amount = math.ceil( + effective_buy_amount * Fraction(self.sell_amount, effective_sell_amount) + ) + return self.buy_amount - current_limit_quote_amount + if self.kind == "buy": + current_quote_sell_amount = int( + effective_sell_amount * Fraction(self.buy_amount, effective_buy_amount) + ) + return current_quote_sell_amount - self.sell_amount + raise ValueError(f"Order kind {self.kind} is invalid.") + + def compute_surplus_fee(self) -> int: + if self.kind == "sell": + buy_amount_clearing_prices = math.ceil( + self.sell_amount + * Fraction( + self.sell_token_clearing_price, self.buy_token_clearing_price + ) + ) + return buy_amount_clearing_prices - self.buy_amount + if self.kind == "buy": + sell_amount_clearing_prices = int( + self.buy_amount + * Fraction( + self.buy_token_clearing_price, self.sell_token_clearing_price + ) + ) + return self.sell_amount - sell_amount_clearing_prices + raise ValueError(f"Order kind {self.kind} is invalid.") + + +# types for protocol fees + + +class FeePolicy(ABC): + """Abstract class for protocol fees + Concrete implementations have to implement a reverse_protocol_fee method. + """ + + # pylint: disable=too-few-public-methods + + @abstractmethod + def reverse_protocol_fee(self, trade: Trade) -> Trade: + """Reverse application of protocol fee + Returns a new trade object + """ + + +@dataclass +class VolumeFeePolicy(FeePolicy): + """Volume based protocol fee""" + + volume_factor: Fraction + + def reverse_protocol_fee(self, trade: Trade) -> Trade: + new_trade = deepcopy(trade) + volume = trade.volume() + if trade.kind == "sell": + fee = round(volume * self.volume_factor / (1 - self.volume_factor)) + new_trade.buy_amount = trade.buy_amount + fee + elif trade.kind == "buy": + fee = round(volume * self.volume_factor / (1 + self.volume_factor)) + new_trade.sell_amount = trade.sell_amount - fee + else: + raise ValueError(f"Order kind {trade.kind} is invalid.") + return new_trade + + +@dataclass +class SurplusFeePolicy(FeePolicy): + """Surplus based protocol fee""" + + surplus_factor: Fraction + surplus_max_volume_factor: Fraction + + def reverse_protocol_fee(self, trade: Trade) -> Trade: + new_trade = deepcopy(trade) + surplus = trade.surplus() + volume = trade.volume() + surplus_fee = round(surplus * self.surplus_factor / (1 - self.surplus_factor)) + if trade.kind == "sell": + volume_fee = round( + volume + * self.surplus_max_volume_factor + / (1 - self.surplus_max_volume_factor) + ) + fee = min(surplus_fee, volume_fee) + new_trade.buy_amount = trade.buy_amount + fee + elif trade.kind == "buy": + volume_fee = round( + volume + * self.surplus_max_volume_factor + / (1 + self.surplus_max_volume_factor) + ) + fee = min(surplus_fee, volume_fee) + new_trade.sell_amount = trade.sell_amount - fee + else: + raise ValueError(f"Order kind {trade.kind} is invalid.") + return new_trade + + +@dataclass +class Quote: + """Class representing quotes""" + + sell_amount: int + buy_amount: int + fee_amount: int + + def effective_sell_amount(self, kind: str) -> int: + if kind == "sell": + return self.sell_amount + if kind == "buy": + return self.sell_amount + self.fee_amount + raise ValueError(f"Order kind {kind} is invalid.") + + def effective_buy_amount(self, kind: str) -> int: + if kind == "sell": + exchange_rate = Fraction(self.buy_amount, self.sell_amount) + return math.ceil((self.sell_amount - self.fee_amount) * exchange_rate) + if kind == "buy": + return self.buy_amount + raise ValueError(f"Order kind {kind} is invalid.") + + +@dataclass +class PriceImprovementFeePolicy(FeePolicy): + """Price improvement based protocol fee""" + + price_improvement_factor: Fraction + price_improvement_max_volume_factor: Fraction + quote: Quote + + def reverse_protocol_fee(self, trade: Trade) -> Trade: + new_trade = deepcopy(trade) + price_improvement = trade.price_improvement(self.quote) + volume = trade.volume() + price_improvement_fee = max( + 0, + round( + price_improvement + * self.price_improvement_factor + / (1 - self.price_improvement_factor) + ), + ) + if trade.kind == "sell": + volume_fee = round( + volume + * self.price_improvement_max_volume_factor + / (1 - self.price_improvement_max_volume_factor) + ) + fee = min(price_improvement_fee, volume_fee) + new_trade.buy_amount = trade.buy_amount + fee + elif trade.kind == "buy": + volume_fee = round( + volume + * self.price_improvement_max_volume_factor + / (1 + self.price_improvement_max_volume_factor) + ) + fee = min(price_improvement_fee, volume_fee) + new_trade.sell_amount = trade.sell_amount - fee + else: + raise ValueError(f"Order kind {trade.kind} is invalid.") + return new_trade + + +@dataclass +class SettlementData: + """Class to describe info about a settlement.""" + + # pylint: disable=too-many-instance-attributes + + auction_id: int + tx_hash: HexBytes + solver: HexBytes + trades: list[Trade] + native_prices: dict[HexBytes, int] + + +# fetching data + + +class OrderbookFetcher: + """ + This is a class for connecting to the orderbook api, and contains a few functions that + fetch necessary data to run the checks that we need. + """ + + def __init__(self) -> None: + load_dotenv() + chain_name = os.getenv("CHAIN_NAME") + + self.orderbook_urls = { + "prod": f"https://api.cow.fi/{chain_name}/api/v1/", + "barn": f"https://barn.api.cow.fi/{chain_name}/api/v1/", + } + + def get_all_data(self, tx_hash: HexBytes) -> SettlementData: + """ + Method that fetches all necessary data from the API. + """ + endpoint_data, environment = self.get_auction_data(tx_hash) + + solutions = endpoint_data["solutions"] + # here we detect the winning solution + for sol in solutions: + if sol["ranking"] == 1: + winning_sol = sol + + auction_id = endpoint_data["auctionId"] + solver = HexBytes(winning_sol["solverAddress"]) + + executed_orders = [ + (HexBytes(order["id"]), int(order["sellAmount"]), int(order["buyAmount"])) + for order in winning_sol["orders"] + ] + clearing_prices = { + HexBytes(address): int(price) + for address, price in winning_sol["clearingPrices"].items() + } + native_prices = { + address: int(endpoint_data["auction"]["prices"][address.hex()]) + for address, _ in clearing_prices.items() + } + trades = [] + for uid, executed_sell_amount, executed_buy_amount in executed_orders: + order_data = self.get_order_data(uid, environment) + if order_data == None: + # this can only happen for now if the order is a jit CoW AMM order + continue + trade_data = self.get_trade_data(uid, tx_hash, environment) + + kind = order_data["kind"] + sell_token = HexBytes(order_data["sellToken"]) + buy_token = HexBytes(order_data["buyToken"]) + limit_sell_amount = int(order_data["sellAmount"]) + limit_buy_amount = int(order_data["buyAmount"]) + sell_token_clearing_price = clearing_prices[sell_token] + buy_token_clearing_price = clearing_prices[buy_token] + fee_policies = self.parse_fee_policies(trade_data["feePolicies"]) + + trade = Trade( + order_uid=uid, + sell_amount=executed_sell_amount, + buy_amount=executed_buy_amount, + sell_token=sell_token, + buy_token=buy_token, + limit_sell_amount=limit_sell_amount, + limit_buy_amount=limit_buy_amount, + kind=kind, + sell_token_clearing_price=sell_token_clearing_price, + buy_token_clearing_price=buy_token_clearing_price, + fee_policies=fee_policies, + ) + trades.append(trade) + + settlement_data = SettlementData( + auction_id=auction_id, + tx_hash=tx_hash, + solver=solver, + trades=trades, + native_prices=native_prices, + ) + return settlement_data + + def get_auction_data(self, tx_hash: HexBytes): + for environment, url in self.orderbook_urls.items(): + try: + response = requests.get( + url + f"solver_competition/by_tx_hash/{tx_hash.hex()}", + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + auction_data = response.json() + return auction_data, environment + except requests.exceptions.HTTPError as err: + if err.response.status_code == 404: + pass + raise ConnectionError(f"Error fetching off-chain data for tx {tx_hash.hex()}") + + def get_order_data(self, uid: HexBytes, environment: str): + prefix = self.orderbook_urls[environment] + url = prefix + f"orders/{uid.hex()}" + response = requests.get( + url, + timeout=REQUEST_TIMEOUT, + ) + if response.ok == False: + # jit CoW AMM detected + return None + order_data = response.json() + return order_data + + def get_trade_data(self, uid: HexBytes, tx_hash: HexBytes, environment: str): + prefix = self.orderbook_urls[environment] + url = prefix + f"trades?orderUid={uid.hex()}" + response = requests.get(url) + trade_data_temp = response.json() + for t in trade_data_temp: + if HexBytes(t["txHash"]) == tx_hash: + trade_data = t + break + return trade_data + + def parse_fee_policies( + self, protocol_fee_datum: list[dict[str, Any]] + ) -> list[FeePolicy]: + """Pase protocol fees into sorted list""" + fee_policies: list[FeePolicy] = [] + for fee_policy in protocol_fee_datum: + if "surplus" in fee_policy: + fee_policies.append( + SurplusFeePolicy( + Fraction(fee_policy["surplus"]["factor"]), + Fraction(fee_policy["surplus"]["maxVolumeFactor"]), + ) + ) + elif "volume" in fee_policy: + fee_policies.append( + VolumeFeePolicy(Fraction(fee_policy["volume"]["factor"])) + ) + elif "priceImprovement" in fee_policy: + quote = Quote( + int(fee_policy["priceImprovement"]["quote"]["sellAmount"]), + int(fee_policy["priceImprovement"]["quote"]["buyAmount"]), + int(fee_policy["priceImprovement"]["quote"]["fee"]), + ) + fee_policies.append( + PriceImprovementFeePolicy( + Fraction(fee_policy["priceImprovement"]["factor"]), + Fraction(fee_policy["priceImprovement"]["maxVolumeFactor"]), + quote, + ) + ) + else: + raise ValueError(f"Fee kind {fee_policy.keys()} is invalid.") + return fee_policies + + +# computing fees +def compute_fee_imbalances( + settlement_data: SettlementData, +) -> tuple[dict[str, int], dict[str, int]]: + protocol_fees: dict[str, int] = {} + network_fees: dict[str, int] = {} + for trade in settlement_data.trades: + # protocol fees + protocol_fee_amount = trade.protocol_fee() + protocol_fee_token = trade.surplus_token() + protocol_fees[protocol_fee_token.to_0x_hex()] = protocol_fee_amount + # network fees + surplus_fee = trade.compute_surplus_fee() # in the surplus token + network_fee = surplus_fee - protocol_fee_amount + if trade.kind == "sell": + network_fee_sell = int( + network_fee + * Fraction( + trade.buy_token_clearing_price, trade.sell_token_clearing_price + ) + ) + else: + network_fee_sell = network_fee + + network_fees[trade.sell_token.to_0x_hex()] = network_fee_sell + + return protocol_fees, network_fees + + +# combined function + + +def batch_fee_imbalances( + tx_hash: HexBytes, +) -> tuple[dict[str, int], dict[str, int]]: + orderbook_api = OrderbookFetcher() + settlement_data = orderbook_api.get_all_data(tx_hash) + protocol_fees, network_fees = compute_fee_imbalances(settlement_data) + return protocol_fees, network_fees diff --git a/src/helpers/database.py b/src/helpers/database.py index 0df7bc9..494ca3c 100644 --- a/src/helpers/database.py +++ b/src/helpers/database.py @@ -97,3 +97,31 @@ def write_prices( "price": price, }, ) + + def write_fees( + self, + chain_name: str, + auction_id: int, + block_number: int, + tx_hash: str, + token_address: str, + fee_amount: float, + fee_type: str, + ): + """Function attempts to write price data to the table.""" + tx_hash_bytes = bytes.fromhex(tx_hash[2:]) + token_address_bytes = bytes.fromhex(token_address[2:]) + + query = read_sql_file("src/sql/insert_fee.sql") + self.execute_and_commit( + query, + { + "chain_name": self.chain_name, + "auction_id": auction_id, + "block_number": block_number, + "tx_hash": tx_hash_bytes, + "token_address": token_address_bytes, + "fee_amount": fee_amount, + "fee_type": fee_type, + }, + ) diff --git a/src/sql/insert_fee.sql b/src/sql/insert_fee.sql new file mode 100644 index 0000000..9f57b22 --- /dev/null +++ b/src/sql/insert_fee.sql @@ -0,0 +1,4 @@ +INSERT INTO fees ( + chain_name, auction_id, block_number, tx_hash, token_address, fee_amount,fee_type +) VALUES ( :chain_name, :auction_id, :block_number, :tx_hash, :token_address, :fee_amount, :fee_type +); diff --git a/src/test_single_hash.py b/src/test_single_hash.py new file mode 100644 index 0000000..351f97a --- /dev/null +++ b/src/test_single_hash.py @@ -0,0 +1,86 @@ +from hexbytes import HexBytes +from web3 import Web3 +from src.imbalances_script import RawTokenImbalances +from src.price_providers.price_feed import PriceFeed +from src.fees.compute_fees import batch_fee_imbalances +from src.transaction_processor import calculate_slippage +from src.helpers.config import get_web3_instance, logger +from contracts.erc20_abi import erc20_abi + + +def log_token_data(title: str, data: dict, name: str): + logger.info(title) + for token, value in data.items(): + logger.info(f"Token Address: {token}, {name}: {value}") + + +class Compute: + """ + Class that allows one to fetch imbalances, fees, final slippage via a tx hash. + """ + + def __init__(self): + self.web3 = get_web3_instance() + self.imbalances = RawTokenImbalances(self.web3, "mainnet") + self.price_providers = PriceFeed() + + def compute_data(self, tx_hash: str): + token_imbalances = self.imbalances.compute_imbalances(tx_hash) + protocol_fees, network_fees = batch_fee_imbalances(HexBytes(tx_hash)) + slippage = calculate_slippage(token_imbalances, protocol_fees, network_fees) + eth_slippage = self.calculate_slippage_in_eth(slippage, tx_hash) + + self.log_results( + token_imbalances, protocol_fees, network_fees, slippage, eth_slippage + ) + + def calculate_slippage_in_eth(self, slippage: dict, tx_hash: str) -> dict: + """Calculate slippage in ETH.""" + eth_slippage = {} + receipt = self.web3.eth.get_transaction_receipt(tx_hash) + if receipt: + block_number = receipt.blockNumber + for token_address, amt in slippage.items(): + if amt != 0: + price_params = { + "block_number": block_number, + "token_address": token_address, + "tx_hash": tx_hash, + } + price_data = self.price_providers.get_price(price_params) + if price_data: + price, _ = price_data + decimals = self.get_token_decimals(token_address) + slippage_in_eth = price * (amt / (10**decimals)) + eth_slippage[token_address] = slippage_in_eth + return eth_slippage + + def get_token_decimals(self, token_address: str) -> int: + contract = self.web3.eth.contract( + address=Web3.to_checksum_address(token_address), abi=erc20_abi + ) + return contract.functions.decimals().call() + + def log_results( + self, + token_imbalances: dict, + protocol_fees: dict, + network_fees: dict, + slippage: dict, + eth_slippage: dict, + ): + log_token_data("Raw Imbalances:", token_imbalances, "Raw Imbalance") + log_token_data("Protocol Fees:", protocol_fees, "Protocol Fee") + log_token_data("Network Fees:", network_fees, "Network Fee") + log_token_data("Raw Slippage Calculation:", slippage, "Raw Slippage") + log_token_data("Slippage in ETH", eth_slippage, "Slippage") + + +def main(): + compute = Compute() + # e.g. input: 0x980fa3f8ff95c504ba61e054e5c3e50ea36b892f865703b8a665564ac0beb1f4 + compute.compute_data(input("tx hash: ")) + + +if __name__ == "__main__": + main() diff --git a/src/transaction_processor.py b/src/transaction_processor.py index 4b42e96..6a3f53d 100644 --- a/src/transaction_processor.py +++ b/src/transaction_processor.py @@ -1,9 +1,11 @@ +from hexbytes import HexBytes from src.helpers.blockchain_data import BlockchainData from src.helpers.database import Database from src.imbalances_script import RawTokenImbalances from src.price_providers.price_feed import PriceFeed from src.helpers.helper_functions import read_sql_file, set_params from src.helpers.config import CHAIN_SLEEP_TIME, logger +from src.fees.compute_fees import batch_fee_imbalances import time @@ -98,9 +100,14 @@ def process_single_transaction( tx_hash, auction_id, block_number, token_address, imbalance ) log_message.append(f"Token: {token_address}, Imbalance: {imbalance}") - for token_address in token_imbalances.keys(): + + protocol_fees, network_fees = batch_fee_imbalances(HexBytes(tx_hash)) + self.handle_fees(protocol_fees, network_fees, auction_id, block_number, tx_hash) + slippage = calculate_slippage(token_imbalances, protocol_fees, network_fees) + + for token_address in slippage.keys(): # fetch price for tokens with non-zero imbalance and write to table - if token_imbalances[token_address] != 0: + if slippage[token_address] != 0: price_data = self.price_providers.get_price( set_params(token_address, block_number, tx_hash) ) @@ -112,3 +119,57 @@ def process_single_transaction( log_message.append(f"Token: {token_address}, Price: {price} ETH") logger.info("\n".join(log_message)) + + def handle_fees( + self, protocol_fees, network_fees, auction_id, block_number, tx_hash + ): + """This function loops over (token, fee) and calls write_fees to write to table.""" + # Write protocol fees + for token_address, fee_amount in protocol_fees.items(): + self.db.write_fees( + chain_name=self.chain_name, + auction_id=auction_id, + block_number=block_number, + tx_hash=tx_hash, + token_address=token_address, + fee_amount=float(fee_amount), + fee_type="protocol", + ) + + # Write network fees + for token_address, fee_amount in network_fees.items(): + self.db.write_fees( + chain_name=self.chain_name, + auction_id=auction_id, + block_number=block_number, + tx_hash=tx_hash, + token_address=token_address, + fee_amount=float(fee_amount), + fee_type="network", + ) + + +def calculate_slippage( + token_imbalances: dict[str, int], + protocol_fees: dict[str, int], + network_fees: dict[str, int], +) -> dict[str, int]: + """Function calculates net slippage for each token per tx.""" + # set of all tokens from all three dicts + all_tokens = ( + set(token_imbalances.keys()) + .union(protocol_fees.keys()) + .union(network_fees.keys()) + ) + slippage = {} + + # calculate net slippage per token + for token in all_tokens: + imbalance = token_imbalances.get(token, 0) + protocol_fee = protocol_fees.get(token, 0) + network_fee = network_fees.get(token, 0) + + total = imbalance - protocol_fee - network_fee + slippage[token] = total + + return slippage