diff --git a/src/fetch/payouts.py b/src/fetch/payouts.py index a8a81dc9..8c9116f7 100644 --- a/src/fetch/payouts.py +++ b/src/fetch/payouts.py @@ -9,13 +9,15 @@ from fractions import Fraction from typing import Callable +import numpy as np import pandas from dune_client.types import Address from pandas import DataFrame, Series +from src.abis.load import WETH9_ADDRESS from src.constants import COW_TOKEN_ADDRESS, COW_BONDING_POOL from src.fetch.dune import DuneFetcher -from src.fetch.prices import eth_in_token, TokenId, token_in_eth +from src.fetch.prices import exchange_rate_atoms from src.models.accounting_period import AccountingPeriod from src.models.overdraft import Overdraft from src.models.token import Token @@ -44,8 +46,13 @@ } REWARD_TARGET_COLUMNS = {"solver", "reward_target", "pool_address"} SERVICE_FEE_COLUMNS = {"solver", "service_fee"} +ADDITIONAL_PAYMENT_COLUMNS = {"buffer_accounting_target", "reward_token_address"} -COMPLETE_COLUMNS = PAYMENT_COLUMNS.union(SLIPPAGE_COLUMNS).union(REWARD_TARGET_COLUMNS) +COMPLETE_COLUMNS = ( + PAYMENT_COLUMNS.union(SLIPPAGE_COLUMNS) + .union(REWARD_TARGET_COLUMNS) + .union(ADDITIONAL_PAYMENT_COLUMNS) +) NUMERICAL_COLUMNS = [ "primary_reward_eth", "primary_reward_cow", @@ -72,13 +79,14 @@ def __init__( # pylint: disable=too-many-arguments self, solver: Address, solver_name: str, - reward_target: Address, - bonding_pool: Address, + reward_target: Address, # recipient address of rewards + buffer_accounting_target: Address, # recipient address of net buffer changes primary_reward_eth: int, slippage_eth: int, primary_reward_cow: int, quote_reward_cow: int, - service_fee: bool, + service_fee: Fraction, + reward_token_address: Address, ): assert quote_reward_cow >= 0, "invalid quote_reward_cow" @@ -86,12 +94,13 @@ def __init__( # pylint: disable=too-many-arguments self.solver = solver self.solver_name = solver_name self.reward_target = reward_target - self.bonding_pool = bonding_pool + self.buffer_accounting_target = buffer_accounting_target self.slippage_eth = slippage_eth self.primary_reward_eth = primary_reward_eth self.primary_reward_cow = primary_reward_cow self.quote_reward_cow = quote_reward_cow self.service_fee = service_fee + self.reward_token_address = reward_token_address @classmethod def from_series(cls, frame: Series) -> RewardAndPenaltyDatum: @@ -103,21 +112,28 @@ def from_series(cls, frame: Series) -> RewardAndPenaltyDatum: ) solver = frame["solver"] reward_target = frame["reward_target"] - bonding_pool = frame["pool_address"] if reward_target is None: - logging.warning(f"solver {solver} without reward_target. Using solver") + logging.warning(f"Solver {solver} without reward_target. Using solver") reward_target = solver + buffer_accounting_target = frame["buffer_accounting_target"] + if buffer_accounting_target is None: + logging.warning( + f"Solver {solver} without buffer_accounting_target. Using solver" + ) + buffer_accounting_target = solver + return cls( solver=Address(solver), solver_name=frame["solver_name"], reward_target=Address(reward_target), - bonding_pool=Address(bonding_pool), + buffer_accounting_target=Address(buffer_accounting_target), slippage_eth=slippage, primary_reward_eth=int(frame["primary_reward_eth"]), primary_reward_cow=int(frame["primary_reward_cow"]), quote_reward_cow=int(frame["quote_reward_cow"]), - service_fee=bool(frame["service_fee"]), + service_fee=Fraction(frame["service_fee"]), + reward_token_address=Address(frame["reward_token_address"]), ) def total_outgoing_eth(self) -> int: @@ -143,7 +159,15 @@ def total_eth_reward(self) -> int: def reward_scaling(self) -> Fraction: """Scaling factor for service fee The reward is multiplied by this factor""" - return 1 - SERVICE_FEE_FACTOR * self.service_fee + return 1 - self.service_fee + + def total_service_fee(self) -> Fraction: + """Total service fee charged from rewards""" + return ( + SERVICE_FEE_FACTOR + * self.service_fee + * (self.primary_reward_cow + self.quote_reward_cow) + ) def total_service_fee(self) -> Fraction: """Scaling factor for service fee @@ -170,7 +194,7 @@ def as_payouts(self) -> list[Transfer]: if quote_reward_cow > 0: result.append( Transfer( - token=Token(COW_TOKEN_ADDRESS), + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=quote_reward_cow, ) @@ -203,11 +227,7 @@ def as_payouts(self) -> list[Transfer]: result.append( Transfer( token=None, - recipient=( - self.reward_target - if self.bonding_pool == COW_BONDING_POOL - else self.solver - ), + recipient=(self.buffer_accounting_target), amount_wei=reimbursement_eth + total_eth_reward, ) ) @@ -226,7 +246,7 @@ def as_payouts(self) -> list[Transfer]: try: result.append( Transfer( - token=Token(COW_TOKEN_ADDRESS), + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=reimbursement_cow + total_cow_reward, ) @@ -243,11 +263,7 @@ def as_payouts(self) -> list[Transfer]: result.append( Transfer( token=None, - recipient=( - self.reward_target - if self.bonding_pool == COW_BONDING_POOL - else self.solver - ), + recipient=(self.buffer_accounting_target), amount_wei=reimbursement_eth, ) ) @@ -258,7 +274,7 @@ def as_payouts(self) -> list[Transfer]: try: result.append( Transfer( - token=Token(COW_TOKEN_ADDRESS), + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=total_cow_reward, ) @@ -278,7 +294,6 @@ class TokenConversion: """ eth_to_token: Callable - token_to_eth: Callable def extend_payment_df(pdf: DataFrame, converter: TokenConversion) -> DataFrame: @@ -416,19 +431,10 @@ def construct_payout_dataframe( normalize_address_field(service_fee_df, join_column) # 3. Merge the three dataframes (joining on solver) - - reward_target_reduced_df_columns = [ - x for x in list(reward_target_df.columns) if x != "solver_name" - ] - reward_target_reduced_df = reward_target_df[reward_target_reduced_df_columns] - service_fee_reduced_df_columns = [ - x for x in list(service_fee_df.columns) if x != "solver_name" - ] - service_fee_reduced_df = service_fee_df[service_fee_reduced_df_columns] merged_df = ( payment_df.merge(slippage_df, on=join_column, how="left") - .merge(reward_target_reduced_df, on=join_column, how="left") - .merge(service_fee_reduced_df, on=join_column, how="left") + .merge(reward_target_df, on=join_column, how="left") + .merge(service_fee_df, on=join_column, how="left") ) # 4. Add slippage from fees to slippage @@ -436,6 +442,25 @@ def construct_payout_dataframe( merged_df["eth_slippage_wei"].fillna(0) + merged_df["network_fee_eth"] ) + # 5. Compute buffer accounting target + merged_df["buffer_accounting_target"] = np.where( + merged_df["pool_address"] != COW_BONDING_POOL.address, + merged_df["solver"], + merged_df["reward_target"], + ) + + # 6. Add reward token address + merged_df["reward_token_address"] = COW_TOKEN_ADDRESS.address + + # 7. Missing service fee is treated as new solver + if any(merged_df["service_fee"].isna()): + missing_solvers = merged_df["solver"].loc[merged_df["service_fee"].isna()] + logging.warning( + f"Solvers {missing_solvers} without service fee info. Using 0%. " + f"Check service fee query." + ) + merged_df["service_fee"] = merged_df["service_fee"].fillna(Fraction(0, 1)) # type: ignore + return merged_df @@ -483,9 +508,6 @@ def construct_payouts( """Workflow of solver reward payout logic post-CIP27""" # pylint: disable-msg=too-many-locals - price_day = dune.period.end - timedelta(days=1) - reward_token = TokenId.COW - quote_rewards_df = orderbook.get_quote_rewards(dune.start_block, dune.end_block) batch_rewards_df = orderbook.get_solver_rewards(dune.start_block, dune.end_block) partner_fees_df = batch_rewards_df[["partner_list", "partner_fee_eth"]] @@ -501,7 +523,8 @@ def construct_payouts( service_fee_df = pandas.DataFrame(dune.get_service_fee_status()) service_fee_df["service_fee"] = [ - datetime.strptime(time_string, "%Y-%m-%d %H:%M:%S.%f %Z") <= dune.period.start + (datetime.strptime(time_string, "%Y-%m-%d %H:%M:%S.%f %Z") <= dune.period.start) + * SERVICE_FEE_FACTOR for time_string in service_fee_df["expires"] ] reward_target_df = pandas.DataFrame(dune.get_vouches()) @@ -521,15 +544,22 @@ def construct_payouts( # TODO - After CIP-20 phased in, adapt query to return `solver` like all the others slippage_df = slippage_df.rename(columns={"solver_address": "solver"}) + reward_token = COW_TOKEN_ADDRESS + native_token = Address(WETH9_ADDRESS) + price_day = dune.period.end - timedelta(days=1) + converter = TokenConversion( + eth_to_token=lambda t: exchange_rate_atoms( + native_token, reward_token, price_day + ) + * t, + ) + complete_payout_df = construct_payout_dataframe( # Fetch and extend auction data from orderbook. payment_df=extend_payment_df( pdf=merged_df, # provide token conversion functions (ETH <--> COW) - converter=TokenConversion( - eth_to_token=lambda t: eth_in_token(reward_token, t, price_day), - token_to_eth=lambda t: token_in_eth(reward_token, t, price_day), - ), + converter=converter, ), # Dune: Fetch Solver Slippage & Reward Targets slippage_df=slippage_df, diff --git a/src/fetch/prices.py b/src/fetch/prices.py index 97442d87..e910124c 100644 --- a/src/fetch/prices.py +++ b/src/fetch/prices.py @@ -7,8 +7,10 @@ import logging.config from datetime import datetime from enum import Enum +from fractions import Fraction from coinpaprika import client as cp +from dune_client.types import Address from src.constants import LOG_CONFIG_FILE @@ -41,32 +43,25 @@ def decimals(self) -> int: return 18 -def eth_in_token(quote_token: TokenId, amount: int, day: datetime) -> int: - """ - Compute how much of `token` is equivalent to `amount` ETH on `day`. - Use current price if day not specified. - """ - eth_amount_usd = token_in_usd(TokenId.ETH, amount, day) - quote_price_usd = token_in_usd(quote_token, 10 ** quote_token.decimals(), day) - return int(eth_amount_usd / quote_price_usd * 10 ** quote_token.decimals()) - - -def token_in_eth(token: TokenId, amount: int, day: datetime) -> int: - """ - The inverse of eth_in_token; - how much ETH is equivalent to `amount` of `token` on `day` - """ - token_amount_usd = token_in_usd(token, amount, day) - eth_price_usd = token_in_usd(TokenId.ETH, 10 ** TokenId.ETH.decimals(), day) +TOKEN_ADDRESS_TO_ID = { + Address("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"): TokenId.ETH, + Address("0xDEf1CA1fb7FBcDC777520aa7f396b4E015F497aB"): TokenId.COW, + Address("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"): TokenId.USDC, +} - return int(token_amount_usd / eth_price_usd * 10 ** TokenId.ETH.decimals()) - -def token_in_usd(token: TokenId, amount_wei: int, day: datetime) -> float: - """ - Converts token amount [wei] to usd amount on given day. +def exchange_rate_atoms( + token_1_address: Address, token_2_address: Address, day: datetime +) -> Fraction: + """Exchange rate for converting tokens on a given day. + The convention for the exchange rate r is as follows: + x atoms of token 1 have the same value as x * r atoms of token 2. """ - return float(amount_wei) * usd_price(token, day) / 10.0 ** token.decimals() + token_1 = TOKEN_ADDRESS_TO_ID[token_1_address] + token_2 = TOKEN_ADDRESS_TO_ID[token_2_address] + price_1 = Fraction(usd_price(token_1, day)) / Fraction(10 ** token_1.decimals()) + price_2 = Fraction(usd_price(token_2, day)) / Fraction(10 ** token_2.decimals()) + return price_1 / price_2 @functools.cache diff --git a/tests/e2e/test_prices.py b/tests/e2e/test_prices.py index b3bc8b9b..13cf7cc5 100644 --- a/tests/e2e/test_prices.py +++ b/tests/e2e/test_prices.py @@ -1,11 +1,14 @@ import unittest from datetime import datetime, timedelta +from dune_client.types import Address + +from src.abis.load import WETH9_ADDRESS +from src.constants import COW_TOKEN_ADDRESS from src.fetch.prices import ( - eth_in_token, + TOKEN_ADDRESS_TO_ID, TokenId, - token_in_eth, - token_in_usd, + exchange_rate_atoms, usd_price, ) @@ -19,48 +22,41 @@ def setUp(self) -> None: self.cow_price = usd_price(TokenId.COW, self.some_date) self.eth_price = usd_price(TokenId.ETH, self.some_date) self.usdc_price = usd_price(TokenId.USDC, self.some_date) + self.cow_address = COW_TOKEN_ADDRESS + self.weth_address = Address(WETH9_ADDRESS) + self.usdc_address = Address("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") def test_usd_price(self): self.assertEqual(self.usdc_price, 1.001622) self.assertEqual(self.eth_price, 2481.89) self.assertEqual(self.cow_price, 0.194899) - def test_token_in_usd(self): + def test_exchange_rate_atoms(self): with self.assertRaises(AssertionError): - token_in_usd(TokenId.COW, ONE_ETH, datetime.today()) + exchange_rate_atoms(self.cow_address, self.weth_address, datetime.today()) self.assertEqual( - token_in_usd(TokenId.ETH, ONE_ETH, self.some_date), self.eth_price - ) - self.assertEqual( - token_in_usd(TokenId.COW, ONE_ETH, self.some_date), self.cow_price + exchange_rate_atoms(self.cow_address, self.cow_address, self.some_date), 1 ) self.assertEqual( - token_in_usd(TokenId.USDC, 10**6, self.some_date), self.usdc_price + exchange_rate_atoms(self.cow_address, self.weth_address, self.some_date), + 1 + / exchange_rate_atoms(self.weth_address, self.cow_address, self.some_date), ) - def test_eth_in_token(self): - self.assertAlmostEqual( - eth_in_token(TokenId.COW, ONE_ETH, self.some_date) / 10**18, - self.eth_price / self.cow_price, - delta=DELTA, - ) - self.assertAlmostEqual( - eth_in_token(TokenId.USDC, ONE_ETH, self.some_date) / 10**6, - self.eth_price / self.usdc_price, - delta=DELTA, + self.assertEqual( + float( + exchange_rate_atoms(self.cow_address, self.weth_address, self.some_date) + ), + self.cow_price / self.eth_price, ) - def test_token_in_eth(self): - self.assertAlmostEqual( - token_in_eth(TokenId.COW, ONE_ETH, self.some_date), - 10**18 * self.cow_price // self.eth_price, - delta=DELTA, - ) - self.assertAlmostEqual( - token_in_eth(TokenId.USDC, 10**6, self.some_date), - 10**18 * self.usdc_price // self.eth_price, - delta=DELTA, + self.assertEqual( + float( + exchange_rate_atoms(self.cow_address, self.usdc_address, self.some_date) + ) + * 10**18, + self.cow_price / self.usdc_price * 10**6, ) def test_price_cache(self): diff --git a/tests/unit/test_payouts.py b/tests/unit/test_payouts.py index 1d4a07cc..f40c2d87 100644 --- a/tests/unit/test_payouts.py +++ b/tests/unit/test_payouts.py @@ -1,10 +1,11 @@ import unittest +from fractions import Fraction import pandas from dune_client.types import Address from pandas import DataFrame -from src.constants import COW_TOKEN_ADDRESS +from src.constants import COW_BONDING_POOL, COW_TOKEN_ADDRESS from src.fetch.payouts import ( extend_payment_df, normalize_address_field, @@ -54,14 +55,19 @@ def setUp(self) -> None: map( str, [ - Address.from_int(9), + COW_BONDING_POOL, Address.from_int(10), Address.from_int(11), Address.from_int(12), ], ) ) - self.service_fee = [False, False, False, True] + self.service_fee = [ + Fraction(0, 100), + Fraction(0, 100), + Fraction(0, 100), + Fraction(15, 100), + ] self.primary_reward_eth = [ 600000000000000.00000, @@ -83,9 +89,7 @@ def setUp(self) -> None: 0.0, ] # Mocking TokenConversion! - self.mock_converter = TokenConversion( - eth_to_token=lambda t: int(t * 1000), token_to_eth=lambda t: t // 1000 - ) + self.mock_converter = TokenConversion(eth_to_token=lambda t: int(t * 1000)) def test_extend_payment_df(self): base_data_dict = { @@ -277,16 +281,28 @@ def test_construct_payouts(self): "0x0000000000000000000000000000000000000008", ], "pool_address": [ - "0x0000000000000000000000000000000000000009", + str(COW_BONDING_POOL), "0x0000000000000000000000000000000000000010", "0x0000000000000000000000000000000000000011", "0x0000000000000000000000000000000000000012", ], "service_fee": [ - False, - False, - False, - True, + Fraction(0, 100), + Fraction(0, 100), + Fraction(0, 100), + Fraction(15, 100), + ], + "buffer_accounting_target": [ + "0x0000000000000000000000000000000000000005", + str(self.solvers[1]), + str(self.solvers[2]), + str(self.solvers[3]), + ], + "reward_token_address": [ + str(COW_TOKEN_ADDRESS), + str(COW_TOKEN_ADDRESS), + str(COW_TOKEN_ADDRESS), + str(COW_TOKEN_ADDRESS), ], } ) @@ -323,16 +339,28 @@ def test_prepare_transfers(self): "0x0000000000000000000000000000000000000008", ], "pool_address": [ - "0x0000000000000000000000000000000000000025", - "0x0000000000000000000000000000000000000026", - "0x0000000000000000000000000000000000000026", "0x5d4020b9261f01b6f8a45db929704b0ad6f5e9e6", + "0x0000000000000000000000000000000000000026", + "0x0000000000000000000000000000000000000027", + "0x0000000000000000000000000000000000000028", ], "service_fee": [ - False, - False, - False, - True, + Fraction(0, 100), + Fraction(0, 100), + Fraction(0, 100), + Fraction(15, 100), + ], + "buffer_accounting_target": [ + self.solvers[0], + "0x0000000000000000000000000000000000000006", + "0x0000000000000000000000000000000000000007", + "0x0000000000000000000000000000000000000008", + ], + "reward_token_address": [ + str(COW_TOKEN_ADDRESS), + str(COW_TOKEN_ADDRESS), + str(COW_TOKEN_ADDRESS), + str(COW_TOKEN_ADDRESS), ], } ) @@ -395,8 +423,8 @@ def setUp(self) -> None: self.solver = Address.from_int(1) self.solver_name = "Solver1" self.reward_target = Address.from_int(2) - self.bonding_pool = Address.from_int(3) - self.cow_token = Token(COW_TOKEN_ADDRESS) + self.buffer_accounting_target = Address.from_int(3) + self.reward_token_address = COW_TOKEN_ADDRESS self.conversion_rate = 1000 def sample_record( @@ -404,19 +432,20 @@ def sample_record( primary_reward: int, slippage: int, num_quotes: int, - service_fee: bool = False, + service_fee: Fraction = Fraction(0, 1), ): """Assumes a conversion rate of ETH:COW <> 1:self.conversion_rate""" return RewardAndPenaltyDatum( solver=self.solver, solver_name=self.solver_name, reward_target=self.reward_target, - bonding_pool=self.bonding_pool, + buffer_accounting_target=self.buffer_accounting_target, primary_reward_eth=primary_reward, primary_reward_cow=primary_reward * self.conversion_rate, slippage_eth=slippage, quote_reward_cow=QUOTE_REWARD_COW * num_quotes, service_fee=service_fee, + reward_token_address=self.reward_token_address, ) def test_invalid_input(self): @@ -443,7 +472,7 @@ def test_reward_datum_pm1_0_0(self): test_datum.as_payouts(), [ Transfer( - token=self.cow_token, + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=primary_reward * self.conversion_rate, ) @@ -465,7 +494,13 @@ def test_reward_datum_0_pm1_0(self): self.assertFalse(test_datum.is_overdraft()) self.assertEqual( test_datum.as_payouts(), - [Transfer(token=None, recipient=self.solver, amount_wei=slippage)], + [ + Transfer( + token=None, + recipient=self.buffer_accounting_target, + amount_wei=slippage, + ) + ], ) # negative slippage gives overdraft @@ -483,7 +518,7 @@ def test_reward_datum_0_0_1(self): test_datum.as_payouts(), [ Transfer( - token=self.cow_token, + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=6000000000000000000 * num_quotes, ) @@ -500,11 +535,11 @@ def test_reward_datum_4_1_0(self): [ Transfer( token=None, - recipient=self.solver, + recipient=self.buffer_accounting_target, amount_wei=slippage, ), Transfer( - token=self.cow_token, + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=(primary_reward) * self.conversion_rate, ), @@ -520,7 +555,7 @@ def test_reward_datum_slippage_reduces_reward(self): test_datum.as_payouts(), [ Transfer( - token=self.cow_token, + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=(primary_reward + slippage) * self.conversion_rate, ), @@ -547,7 +582,7 @@ def test_reward_datum_reward_reduces_slippage(self): [ Transfer( token=None, - recipient=self.solver, + recipient=self.buffer_accounting_target, amount_wei=test_datum.total_outgoing_eth(), ), ], @@ -555,7 +590,7 @@ def test_reward_datum_reward_reduces_slippage(self): def test_performance_reward_service_fee(self): """Sevice fee reduces COW reward.""" - primary_reward, num_quotes, service_fee = 100, 0, True + primary_reward, num_quotes, service_fee = 100, 0, Fraction(15, 100) test_datum = self.sample_record( primary_reward=primary_reward, slippage=0, @@ -567,7 +602,7 @@ def test_performance_reward_service_fee(self): test_datum.as_payouts(), [ Transfer( - token=self.cow_token, + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=int(primary_reward * (1 - SERVICE_FEE_FACTOR)) * self.conversion_rate, @@ -577,7 +612,7 @@ def test_performance_reward_service_fee(self): def test_quote_reward_service_fee(self): """Sevice fee reduces COW reward.""" - primary_reward, num_quotes, service_fee = 0, 100, True + primary_reward, num_quotes, service_fee = 0, 100, Fraction(15, 100) test_datum = self.sample_record( primary_reward=primary_reward, slippage=0, @@ -589,7 +624,7 @@ def test_quote_reward_service_fee(self): test_datum.as_payouts(), [ Transfer( - token=self.cow_token, + token=Token(self.reward_token_address), recipient=self.reward_target, amount_wei=int( 6000000000000000000 * num_quotes * (1 - SERVICE_FEE_FACTOR)