From 3645d920555222fd4c3b2c62dd0b89472f8bb399 Mon Sep 17 00:00:00 2001 From: Felix Henneke Date: Mon, 11 Nov 2024 17:36:30 +0100 Subject: [PATCH] Remove constants from payout datum (#418) This PR makes the payout datum independent of constants. This will make it easier to pass a config object through the code, as it will not be needed to pass to the creation of transfers from a payout datum. - This PR adds an explicit separation between reward and buffer accounting target. This way we do not need to check for the bonding pool when creating transfers. This change requires adding a new column, `"buffer_accounting_target"`, to the payment dataframe. - The type of the service fee field is changed to `Fraction` from `bool`. This makes it possible to avoid passing the service fee factor to several functions related to creating transfers. This requires a change in meaning of the `"service_fee"` column. - The reward token address (of COW, which might depend on the network) is explicitly part of the payment datum. This requires adding a column `"reward_target_address"`, to the payment dataframe. Tests are adapted accordingly. I have not yet tested the effect on payments. In principle, the behavior should not change. --- src/fetch/payouts.py | 75 +++++++++++++++++++---------- tests/unit/test_payouts.py | 97 ++++++++++++++++++++++++++------------ 2 files changed, 118 insertions(+), 54 deletions(-) diff --git a/src/fetch/payouts.py b/src/fetch/payouts.py index 883302fe..bca044f3 100644 --- a/src/fetch/payouts.py +++ b/src/fetch/payouts.py @@ -9,6 +9,7 @@ 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 @@ -45,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", @@ -73,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" @@ -87,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: @@ -104,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: @@ -136,7 +151,7 @@ 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""" @@ -162,7 +177,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, ) @@ -195,11 +210,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, ) ) @@ -218,7 +229,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, ) @@ -235,11 +246,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, ) ) @@ -250,7 +257,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, ) @@ -418,6 +425,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 @@ -480,7 +506,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()) diff --git a/tests/unit/test_payouts.py b/tests/unit/test_payouts.py index 5bfad642..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, @@ -275,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), ], } ) @@ -321,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), ], } ) @@ -393,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( @@ -402,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): @@ -441,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, ) @@ -463,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 @@ -481,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, ) @@ -498,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, ), @@ -518,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, ), @@ -545,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(), ), ], @@ -553,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, @@ -565,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, @@ -575,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, @@ -587,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)