From 51ac00ac41d48d6c8eb329340a16d23478701f65 Mon Sep 17 00:00:00 2001 From: trizin <25263018+trizin@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:52:42 +0000 Subject: [PATCH 1/4] distribute rewards per epoch to bound single-prediction capture --- df_py/predictoor/calc_rewards.py | 88 +++++++++++++------ .../test/test_predictoor_calc_rewards.py | 48 +++++++++- 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/df_py/predictoor/calc_rewards.py b/df_py/predictoor/calc_rewards.py index 45c043ef1..30a26e830 100644 --- a/df_py/predictoor/calc_rewards.py +++ b/df_py/predictoor/calc_rewards.py @@ -6,66 +6,98 @@ from df_py.predictoor.queries import query_predictoor_contracts from df_py.util.graphutil import wait_to_latest_block +WEEK_SECONDS = 7 * 24 * 60 * 60 + @enforce_types def calc_predictoor_rewards( predictoors: Dict[str, Predictoor], tokens_avail: Union[int, float], chain_id: int ) -> Dict[str, Dict[str, float]]: """ - Calculate rewards for predictoors based on their weekly payout. + Calculate rewards for predictoors, distributed per epoch (slot). + + The budget is split in three stages: + 1. equally across prediction feeds (contracts), + 2. equally across all possible weekly epochs (slots) for that feed, + 3. within each epoch, proportionally to each predictoor's positive + profit (payout - stake) for that epoch. + + Splitting by epoch first bounds how much a single position can capture: + one large prediction in one epoch can win at most that epoch's small + slice of the budget, not the whole weekly feed budget. This makes + "both-siding" across unlinkable wallets far less profitable, since the + manufactured-profit wallet can only ever drain a per-epoch budget. @arguments predictoors -- dict of [pdr_address] : Predictoor objects The predictoors to calculate rewards for. tokens_avail -- float The number of tokens available for distribution as rewards. + chain_id -- int + The chain to query the available feeds (contracts) from. @return rewards -- dict of [contract addr][predictoor addr]: float - The calculated rewards for each predictoor per contract address. + The calculated rewards for each predictoor per contract address, + aggregated across all epochs of that contract. """ MIN_REWARD = 1e-15 tokens_avail = float(tokens_avail) wait_to_latest_block(chain_id) - predictoor_contracts = query_predictoor_contracts(chain_id).keys() + predictoor_contracts = query_predictoor_contracts(chain_id) print("# of available contracts: ", len(predictoor_contracts)) tokens_per_contract = tokens_avail / len(predictoor_contracts) print("Tokens per contract:", tokens_per_contract) # dict to store rewards per contract rewards: Dict[str, Dict[str, float]] = { - contract: {} for contract in predictoor_contracts + contract: {} for contract in predictoor_contracts.keys() } - # Loop through each contract and calculate the rewards for predictions - # made for that specific contract - for contract in predictoor_contracts: - total_revenue_for_contract = 0 - for p in predictoors.values(): - summary = p.get_prediction_summary(contract) - total_revenue_for_contract += max( - summary.total_revenue, 0 - ) # ignore negative values - - # If total revenue for this contract is 0, no rewards are distributed - if total_revenue_for_contract == 0: - print("Total revenue for contract: ", contract, " was zero") + for contract, contract_obj in predictoor_contracts.items(): + # Build per-epoch profits for this contract: + # epoch_profits[slot][pdr_address] = summed profit for that epoch + epoch_profits: Dict[int, Dict[str, float]] = {} + for pdr_address, predictoor in predictoors.items(): + for prediction in predictoor._predictions: + if prediction.contract_addr != contract: + continue + slot_profits = epoch_profits.setdefault(prediction.slot, {}) + slot_profits[pdr_address] = ( + slot_profits.get(pdr_address, 0.0) + prediction.revenue + ) + + seconds_per_epoch = contract_obj.blocks_per_epoch + num_epochs = int(WEEK_SECONDS / seconds_per_epoch) + if num_epochs == 0: + print("No epochs for contract: ", contract) continue - # Calculate rewards for each predictoor for this contract - for pdr_address, predictoor in predictoors.items(): - revenue_contract = predictoor.get_prediction_summary(contract).total_revenue - if revenue_contract <= 0: - # ignore negative revenues - continue - reward_amt = ( - revenue_contract / total_revenue_for_contract * tokens_per_contract - ) - if reward_amt < MIN_REWARD: + # Each epoch gets an equal slice of this contract's budget. + epoch_budget = tokens_per_contract / num_epochs + + for slot_profits in epoch_profits.values(): + total_positive = sum(max(p, 0.0) for p in slot_profits.values()) + + # If nobody profited this epoch, its budget is not distributed. + if total_positive == 0: continue - rewards[contract][pdr_address] = reward_amt + + for pdr_address, profit in slot_profits.items(): + if profit <= 0: + # ignore non-positive (losing) profits + continue + reward_amt = profit / total_positive * epoch_budget + rewards[contract][pdr_address] = ( + rewards[contract].get(pdr_address, 0.0) + reward_amt + ) + + # drop dust amounts + rewards[contract] = { + addr: amt for addr, amt in rewards[contract].items() if amt >= MIN_REWARD + } return rewards diff --git a/df_py/predictoor/test/test_predictoor_calc_rewards.py b/df_py/predictoor/test/test_predictoor_calc_rewards.py index f9ae51d12..452c8ccf3 100644 --- a/df_py/predictoor/test/test_predictoor_calc_rewards.py +++ b/df_py/predictoor/test/test_predictoor_calc_rewards.py @@ -5,6 +5,7 @@ import pytest from df_py.predictoor.calc_rewards import ( + WEEK_SECONDS, aggregate_predictoor_rewards, calc_predictoor_rewards, ) @@ -13,10 +14,18 @@ from df_py.util.reward_shaper import RewardShaper +class MockPredictContract: + def __init__(self, seconds_per_epoch): + self.blocks_per_epoch = seconds_per_epoch + + @pytest.fixture(autouse=True) def mock_query_functions(): with patch("df_py.predictoor.calc_rewards.query_predictoor_contracts") as mock: - mock.return_value = {"0xContract1": "", "0xContract2": ""} + mock.return_value = { + "0xContract1": MockPredictContract(WEEK_SECONDS), + "0xContract2": MockPredictContract(WEEK_SECONDS), + } yield @@ -130,6 +139,43 @@ def test_reward_calculation_with_negative(): assert len(rewards["0xContract2"]) == 0 +def test_epoch_based_rewards_bound_single_epoch_capture(): + with patch("df_py.predictoor.calc_rewards.query_predictoor_contracts") as mock: + mock.return_value = { + # 10 possible weekly epochs. A predictoor active in one epoch can + # win at most 1/10 of this contract's budget. + "0xContract1": MockPredictContract(WEEK_SECONDS / 10), + } + + whale = Predictoor("0x1") + whale.add_prediction(Prediction(1, 10000.0, 0.0, "0xContract1")) + + rewards = calc_predictoor_rewards({"0x1": whale}, 100, DEV_CHAINID) + + assert rewards["0xContract1"]["0x1"] == 10.0 + + +def test_epoch_based_rewards_do_not_compare_profit_across_epochs(): + with patch("df_py.predictoor.calc_rewards.query_predictoor_contracts") as mock: + mock.return_value = { + # Two possible weekly epochs, so each epoch has 50 tokens. + "0xContract1": MockPredictContract(WEEK_SECONDS / 2), + } + + whale = Predictoor("0x1") + whale.add_prediction(Prediction(1, 10000.0, 0.0, "0xContract1")) + + small = Predictoor("0x2") + small.add_prediction(Prediction(2, 1.0, 0.0, "0xContract1")) + + rewards = calc_predictoor_rewards( + {"0x1": whale, "0x2": small}, 100, DEV_CHAINID + ) + + assert rewards["0xContract1"]["0x1"] == 50.0 + assert rewards["0xContract1"]["0x2"] == 50.0 + + def test_calc_predictoor_rewards_fuzz(): predictoors = {} for i in range(100): # generate 100 predictoors From 3108829aaa8963b544ab43b5d6908ce4e65e9efa Mon Sep 17 00:00:00 2001 From: trizin <25263018+trizin@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:58:09 +0000 Subject: [PATCH 2/4] linter --- df_py/predictoor/calc_rewards.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/df_py/predictoor/calc_rewards.py b/df_py/predictoor/calc_rewards.py index 30a26e830..db30b2675 100644 --- a/df_py/predictoor/calc_rewards.py +++ b/df_py/predictoor/calc_rewards.py @@ -53,7 +53,7 @@ def calc_predictoor_rewards( # dict to store rewards per contract rewards: Dict[str, Dict[str, float]] = { - contract: {} for contract in predictoor_contracts.keys() + contract: {} for contract in predictoor_contracts } for contract, contract_obj in predictoor_contracts.items(): From b3cd27552be57d7d334197508986fa0410ebffd5 Mon Sep 17 00:00:00 2001 From: trizin <25263018+trizin@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:06:14 +0000 Subject: [PATCH 3/4] fix code --- df_py/predictoor/calc_rewards.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/df_py/predictoor/calc_rewards.py b/df_py/predictoor/calc_rewards.py index db30b2675..2f7de696a 100644 --- a/df_py/predictoor/calc_rewards.py +++ b/df_py/predictoor/calc_rewards.py @@ -69,7 +69,7 @@ def calc_predictoor_rewards( slot_profits.get(pdr_address, 0.0) + prediction.revenue ) - seconds_per_epoch = contract_obj.blocks_per_epoch + seconds_per_epoch = int(contract_obj.blocks_per_epoch) num_epochs = int(WEEK_SECONDS / seconds_per_epoch) if num_epochs == 0: print("No epochs for contract: ", contract) From d0b892d3e96238e94955a00a33446d199434186e Mon Sep 17 00:00:00 2001 From: trizin <25263018+trizin@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:06:20 +0000 Subject: [PATCH 4/4] fix --- df_py/predictoor/test/test_predictoor_calc_rewards.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/df_py/predictoor/test/test_predictoor_calc_rewards.py b/df_py/predictoor/test/test_predictoor_calc_rewards.py index 452c8ccf3..6eacade1c 100644 --- a/df_py/predictoor/test/test_predictoor_calc_rewards.py +++ b/df_py/predictoor/test/test_predictoor_calc_rewards.py @@ -144,7 +144,7 @@ def test_epoch_based_rewards_bound_single_epoch_capture(): mock.return_value = { # 10 possible weekly epochs. A predictoor active in one epoch can # win at most 1/10 of this contract's budget. - "0xContract1": MockPredictContract(WEEK_SECONDS / 10), + "0xContract1": MockPredictContract(WEEK_SECONDS // 10), } whale = Predictoor("0x1") @@ -159,7 +159,7 @@ def test_epoch_based_rewards_do_not_compare_profit_across_epochs(): with patch("df_py.predictoor.calc_rewards.query_predictoor_contracts") as mock: mock.return_value = { # Two possible weekly epochs, so each epoch has 50 tokens. - "0xContract1": MockPredictContract(WEEK_SECONDS / 2), + "0xContract1": MockPredictContract(WEEK_SECONDS // 2), } whale = Predictoor("0x1")