From 171c6fc29f877434fd1878b8d79f082eba3c6a59 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Mon, 23 Dec 2024 10:28:02 +0100 Subject: [PATCH 01/14] wip - working on regression --- backend/v2/epoch_snapshots/repositories.py | 16 +- backend/v2/epochs/dependencies.py | 35 +- backend/v2/epochs/subgraphs.py | 45 +- .../v2/project_rewards/capped_quadriatic.py | 6 +- backend/v2/project_rewards/repositories.py | 37 ++ backend/v2/project_rewards/router.py | 478 +++++++++++++++++- backend/v2/project_rewards/sablier.py | 112 ++++ backend/v2/project_rewards/schemas.py | 112 ++-- backend/v2/project_rewards/services.py | 43 ++ backend/v2/project_rewards/user_events.py | 203 ++++++++ backend/v2/projects/dependencies.py | 14 +- backend/v2/user_patron_mode/repositories.py | 24 +- 12 files changed, 1056 insertions(+), 69 deletions(-) create mode 100644 backend/v2/project_rewards/repositories.py create mode 100644 backend/v2/project_rewards/sablier.py create mode 100644 backend/v2/project_rewards/user_events.py diff --git a/backend/v2/epoch_snapshots/repositories.py b/backend/v2/epoch_snapshots/repositories.py index b1e3117d90..c392a8cd9b 100644 --- a/backend/v2/epoch_snapshots/repositories.py +++ b/backend/v2/epoch_snapshots/repositories.py @@ -1,4 +1,7 @@ -from app.infrastructure.database.models import PendingEpochSnapshot +from app.infrastructure.database.models import ( + FinalizedEpochSnapshot, + PendingEpochSnapshot, +) from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,3 +13,14 @@ async def get_pending_epoch_snapshot( select(PendingEpochSnapshot).filter(PendingEpochSnapshot.epoch == epoch_number) ) return result.scalar_one_or_none() + + +async def get_finalized_epoch_snapshot( + session: AsyncSession, epoch_number: int +) -> FinalizedEpochSnapshot | None: + result = await session.execute( + select(FinalizedEpochSnapshot).filter( + FinalizedEpochSnapshot.epoch == epoch_number + ) + ) + return result.scalar_one_or_none() diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py index 67f391f599..da4495df6d 100644 --- a/backend/v2/epochs/dependencies.py +++ b/backend/v2/epochs/dependencies.py @@ -8,7 +8,12 @@ from app.exceptions import InvalidEpoch from app.modules.staking.proceeds.core import ESTIMATED_STAKING_REWARDS_RATE -from v2.core.dependencies import OctantSettings, Web3 +from app.context.epoch_state import EpochState +from v2.epoch_snapshots.repositories import ( + get_finalized_epoch_snapshot, + get_pending_epoch_snapshot, +) +from v2.core.dependencies import GetSession, OctantSettings, Web3 from v2.core.exceptions import AllocationWindowClosed from v2.core.transformers import transform_to_checksum_address from v2.core.types import Address @@ -81,6 +86,34 @@ async def get_rewards_rate(epoch_number: int) -> float: return ESTIMATED_STAKING_REWARDS_RATE +async def get_epoch_state( + session: GetSession, epochs_contracts: GetEpochsContracts, epoch_number: int +) -> EpochState: + current_epoch_number = await get_current_epoch(epoch_number) + if epoch_number > current_epoch_number: + return EpochState.FUTURE + + if epoch_number == current_epoch_number: + return EpochState.CURRENT + + pending_epoch_number = await epochs_contracts.get_pending_epoch() + pending_snapshot = await get_pending_epoch_snapshot(session, epoch_number) + if epoch_number == pending_epoch_number: + if pending_snapshot is None: + return EpochState.PRE_PENDING + else: + return EpochState.PENDING + + if pending_snapshot is None: + raise InvalidEpoch() + + finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) + if finalized_snapshot is None: + return EpochState.FINALIZING + else: + return EpochState.FINALIZED + + GetEpochsSubgraph = Annotated[ EpochsSubgraph, Depends(get_epochs_subgraph), diff --git a/backend/v2/epochs/subgraphs.py b/backend/v2/epochs/subgraphs.py index 8ddfd8a91c..9c446ff684 100644 --- a/backend/v2/epochs/subgraphs.py +++ b/backend/v2/epochs/subgraphs.py @@ -2,8 +2,10 @@ from dataclasses import dataclass from typing import Callable, Sequence, Type, Union +from pydantic import TypeAdapter import backoff from app import exceptions +from v2.core.types import OctantModel from v2.core.exceptions import EpochsNotFound from app.context.epoch.details import EpochDetails from gql import Client, gql @@ -42,6 +44,14 @@ class BackoffParams: giveup: Callable[[Exception], bool] = lambda e: False +class EpochSubgraphItem(OctantModel): + epoch: int + fromTs: int + toTs: int + duration: int + decisionWindow: int + + class EpochsSubgraph: def __init__( self, @@ -66,14 +76,8 @@ def __init__( self.gql_client.execute_async ) - async def get_epoch_by_number(self, epoch_number: int) -> EpochDetails: + async def fetch_epoch_by_number(self, epoch_number: int) -> EpochSubgraphItem: """Get EpochDetails from the subgraph for a given epoch number.""" - - logging.debug( - f"[Subgraph] Getting epoch properties for epoch number: {epoch_number}" - ) - - # Prepare query and variables query = gql( """\ query GetEpoch($epochNo: Int!) { @@ -85,31 +89,28 @@ async def get_epoch_by_number(self, epoch_number: int) -> EpochDetails: decisionWindow } } - """ + """ ) variables = {"epochNo": epoch_number} - - # Execute query + response = await self.gql_client.execute_async(query, variable_values=variables) - - # Raise exception if no data received + data = response["epoches"] if not data: - logging.warning( - f"[Subgraph] No epoch properties received for epoch number: {epoch_number}" - ) raise exceptions.EpochNotIndexed(epoch_number) - # Parse response and return result - logging.debug(f"[Subgraph] Received epoch properties: {data[0]}") + return TypeAdapter(EpochSubgraphItem).validate_python(data[0]) - epoch_details = data[0] + async def get_epoch_by_number(self, epoch_number: int) -> EpochDetails: + """Get EpochDetails from the subgraph for a given epoch number.""" + + epoch_details = await self.fetch_epoch_by_number(epoch_number) return EpochDetails( - epoch_num=epoch_details["epoch"], - start=epoch_details["fromTs"], - duration=epoch_details["duration"], - decision_window=epoch_details["decisionWindow"], + epoch_num=epoch_details.epoch, + start=epoch_details.fromTs, + duration=epoch_details.duration, + decision_window=epoch_details.decisionWindow, remaining_sec=0, ) diff --git a/backend/v2/project_rewards/capped_quadriatic.py b/backend/v2/project_rewards/capped_quadriatic.py index 8fa3197c94..22751b3b17 100644 --- a/backend/v2/project_rewards/capped_quadriatic.py +++ b/backend/v2/project_rewards/capped_quadriatic.py @@ -5,11 +5,11 @@ from v2.allocations.schemas import AllocationWithUserUQScore from v2.core.types import Address -from v2.project_rewards.schemas import ProjectFundingSummary +from v2.project_rewards.schemas import ProjectFundingSummaryV1 class CappedQuadriaticFunding(NamedTuple): - project_fundings: dict[Address, ProjectFundingSummary] + project_fundings: dict[Address, ProjectFundingSummaryV1] amounts_total: Decimal # Sum of all allocation amounts for all projects matched_total: Decimal # Sum of all matched rewards for all projects @@ -96,7 +96,7 @@ def capped_quadriatic_funding( matched_total += matched_capped project_fundings = { - project_address: ProjectFundingSummary( + project_address: ProjectFundingSummaryV1( address=project_address, allocated=int(amount_by_project[project_address]), matched=int(matched_by_project[project_address]), diff --git a/backend/v2/project_rewards/repositories.py b/backend/v2/project_rewards/repositories.py new file mode 100644 index 0000000000..359eea93fe --- /dev/null +++ b/backend/v2/project_rewards/repositories.py @@ -0,0 +1,37 @@ +from email.headerregistry import Address +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.infrastructure.database.models import Reward + + +async def get_rewards_for_epoch( + session: AsyncSession, epoch_number: int +) -> list[Reward]: + """ + Get all rewards for a given epoch. + """ + + results = await session.scalars( + select(Reward) + .where(Reward.epoch == epoch_number) + .order_by(Reward.address.asc()) + ) + + return list(results.all()) + + +async def get_rewards_for_projects_in_epoch( + session: AsyncSession, epoch_number: int, projects_addresses: list[Address] +) -> list[Reward]: + """ + Get all rewards for a given epoch for a list of projects. + """ + + results = await session.scalars( + select(Reward) + .where(Reward.epoch == epoch_number) + .where(Reward.address.in_(projects_addresses)) + .order_by(Reward.address.asc()) + ) + + return list(results.all()) diff --git a/backend/v2/project_rewards/router.py b/backend/v2/project_rewards/router.py index 7a68f8e531..48f1a258b1 100644 --- a/backend/v2/project_rewards/router.py +++ b/backend/v2/project_rewards/router.py @@ -1,14 +1,378 @@ from fastapi import APIRouter +from app.context.epoch_state import EpochState +from app.exceptions import NotImplementedForGivenEpochState +from v2.allocations.repositories import ( + get_donors_for_epoch, + sum_allocations_by_epoch, +) +from v2.core.types import Address +from v2.epoch_snapshots.repositories import get_finalized_epoch_snapshot +from v2.epochs.dependencies import ( + GetEpochsContracts, + GetEpochsSubgraph, + get_epoch_state, +) +from v2.project_rewards.repositories import get_rewards_for_projects_in_epoch +from v2.projects.dependencies import ( + GetProjectsAllocationThresholdGetter, + GetProjectsContracts, +) +from v2.user_patron_mode.repositories import ( + get_all_patrons_at_timestamp, + get_all_users_budgets_by_epoch, + get_budget_by_user_address_and_epoch, +) +from v2.core.dependencies import GetSession +from v2.project_rewards.services import get_rewards_merkle_tree_for_epoch from v2.project_rewards.dependencies import GetProjectRewardsEstimator -from v2.project_rewards.schemas import EstimatedProjectRewardsResponse +from v2.project_rewards.schemas import ( + EpochBudgetItemV1, + EpochBudgetsResponseV1, + EstimatedBudgetByDaysRequestV1, + EstimatedBudgetByEpochRequestV1, + EstimatedProjectRewardsResponseV1, + ProjectFundingSummaryV1, + RewardsLeverageResponseV1, + RewardsMerkleTreeResponseV1, + ThresholdResponseV1, + UnusedRewardsResponseV1, + UpcomingUserBudgetResponseV1, + UserBudgetResponseV1, + UserBudgetWithMatchedFundingResponseV1, +) -api = APIRouter(prefix="/rewards", tags=["Allocations"]) +api = APIRouter(prefix="/rewards", tags=["Rewards"]) + + +@api.get("/budget/{user_address}/epoch/{epoch_number}") +async def get_user_budget_for_epoch_v1( + session: GetSession, + user_address: Address, + epoch_number: int, +) -> UserBudgetResponseV1: + """ + Returns user's rewards budget available to allocate for given epoch + """ + budget = await get_budget_by_user_address_and_epoch( + session, + user_address, + epoch_number, + ) + + # Q: Should we return 0 or raise exception when user has no budget for the epoch? + + return UserBudgetResponseV1(budget=budget or 0) + + +@api.get("/budget/{user_address}/upcoming") +async def get_user_budget_for_upcoming_epoch_v1( + + user_address: Address, +) -> UpcomingUserBudgetResponseV1: + """ + Returns the upcoming user budget based on if allocation happened now. + """ + # TODO we need to handle snapshots here unfortunatelly + upcoming_budget = await get_upcoming_user_budget(user_address) + + # context = state_context(EpochState.SIMULATED, with_block_range=True) + + # user_deposits = CalculatedUserDeposits( + # events_generator=DbAndGraphEventsGenerator() + # ) + # octant_rewards = CalculatedOctantRewards( + # staking_proceeds=EstimatedStakingProceeds(), + # effective_deposits=user_deposits, + # ) + # UpcomingUserBudgets( + # simulated_pending_snapshot_service=SimulatedPendingSnapshots( + # effective_deposits=user_deposits, octant_rewards=octant_rewards + # ) + # ) + + + + # simulated_snapshot = ( + # self.simulated_pending_snapshot_service.simulate_pending_epoch_snapshot( + # context + # ) + # ) + + # def _calculate_pending_epoch_snapshot(self, context: Context) -> PendingSnapshotDTO: + # rewards = self.octant_rewards.get_octant_rewards(context) + + # def get_octant_rewards(self, context: Context) -> OctantRewardsDTO: + # eth_proceeds = self.staking_proceeds.get_staking_proceeds(context) + # def get_staking_proceeds(self, context: Context) -> int: + # return estimate_staking_proceeds(context.epoch_details.duration_sec) + + # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) + # duration_sec = epoch_details.duration + # return estimate_staking_proceeds(duration_sec) + + # total_effective_deposit = self.effective_deposits.get_total_effective_deposit( + # context + # ) + # def get_total_effective_deposit(self, context: Context) -> int: + # events = self.events_generator.get_all_users_events(context) + # _, total = calculate_effective_deposits( + # context.epoch_details, context.epoch_settings, events + # ) + # return total + + + # rewards_settings = context.epoch_settings.octant_rewards + # octant_rewards = calculate_rewards( + # rewards_settings, eth_proceeds, total_effective_deposit + # ) + # ( + # locked_ratio, + # total_rewards, + # vanilla_individual_rewards, + # op_cost, + # ppf, + # community_fund, + # ) = ( + # octant_rewards.locked_ratio, + # octant_rewards.total_rewards, + # octant_rewards.vanilla_individual_rewards, + # octant_rewards.operational_cost, + # octant_rewards.ppf_value, + # octant_rewards.community_fund, + # ) + + # return OctantRewardsDTO( + # staking_proceeds=eth_proceeds, + # locked_ratio=locked_ratio, + # total_effective_deposit=total_effective_deposit, + # total_rewards=total_rewards, + # vanilla_individual_rewards=vanilla_individual_rewards, + # operational_cost=op_cost, + # ppf=ppf, + # community_fund=community_fund, + # ) + + # ( + # user_deposits, + # _, + # ) = self.effective_deposits.get_all_effective_deposits(context) + # user_budgets = calculate_user_budgets( + # context.epoch_settings.user.budget, rewards, user_deposits + # ) + + # return PendingSnapshotDTO( + # rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets + # ) + + # user_budget = next( + # filter( + # lambda budget_info: budget_info.user_address == user_address, + # upcoming_user_budgets, + # ), + # None, + # ) + # if not user_budget: + # return 0 + # return user_budget.budget + + # upcoming_budget = core.get_upcoming_budget( + # user_address, simulated_snapshot.user_budgets + # ) + + # return upcoming_budget + + return UpcomingUserBudgetResponseV1(upcoming_budget=upcoming_budget) + + + + + +@api.get("/budgets/epoch/{epoch_number}") +async def get_epoch_budgets_v1( + session: GetSession, + epoch_number: int, +) -> EpochBudgetsResponseV1: + """ + Returns all users rewards budgets for the epoch. + """ + budgets = await get_all_users_budgets_by_epoch(session, epoch_number) + + return EpochBudgetsResponseV1( + budgets=[ + EpochBudgetItemV1(address=address, amount=amount) + for address, amount in budgets.items() + ] + ) + + +@api.post("/estimated_budget") +async def get_estimated_budget_v1( + request: EstimatedBudgetByEpochRequestV1, +) -> UserBudgetWithMatchedFundingResponseV1: + # leverage = octant_rewards_controller.get_last_finalized_epoch_leverage() + # context = state_context(EpochState.FINALIZED) + # service = get_services(context.epoch_state).octant_rewards_service + + # return service.get_leverage(context) + # allocations_sum = database.allocations.get_alloc_sum_by_epoch( + # context.epoch_details.epoch_num + # ) + # finalized_snapshot = database.finalized_epoch_snapshot.get_by_epoch( + # context.epoch_details.epoch_num + # ) + # matched_rewards = int(finalized_snapshot.matched_rewards) + + # return context.epoch_settings.project.rewards.leverage.calculate_leverage( + # matched_rewards, allocations_sum + # ) + # Leverage: + # return matched_rewards / total_allocated if total_allocated else 0 + + # epochs_budget = budget_controller.estimate_epochs_budget(no_epochs, glm_amount) + # validate_estimate_budget_by_epochs_inputs(no_epochs, glm_amount) + + # future_context = state_context(EpochState.FUTURE) + # future_rewards_service = get_services(EpochState.FUTURE).octant_rewards_service + # future_rewards = future_rewards_service.get_octant_rewards(future_context) + + # def _get_future_epoch_details(epoch_num: int) -> EpochDetails: + # epoch_details = epochs.get_future_epoch_props() + # start = epoch_details[2] + # duration = epoch_details[3] + # decision_window = epoch_details[4] + # return EpochDetails( + # epoch_num=epoch_num, + # start=start, + # duration=duration, + # decision_window=decision_window, + # remaining_sec=duration, + # ) + + # return FutureServices( + # octant_rewards_service=CalculatedOctantRewards( + # staking_proceeds=EstimatedStakingProceeds(), + # effective_deposits=ContractBalanceUserDeposits(), + # ), + # projects_metadata_service=StaticProjectsMetadataService(), + # projects_details_service=StaticProjectsDetailsService(), + # ) + + # future_rewards = await get_octant_rewards(session, epoch_number, start_sec, end_sec) + + + # epoch_duration = future_context.epoch_details.duration_sec + + # return no_epochs * core.estimate_epoch_budget( + # future_context, future_rewards, epoch_duration, glm_amount + # ) + + matching_fund = budget_controller.get_matching_fund(epochs_budget, leverage) + # def get_matching_fund(budget: int, leverage: float) -> int: + # return core.calculate_matching_fund(budget, leverage) + # def calculate_matching_fund(budget: int, leverage: float) -> int: + # return int(budget * leverage) + + return EstimatedRewardsDTO( + estimated_budget=epochs_budget, leverage=leverage, matching_fund=matching_fund + ) + + +@api.post("/estimated_budget/by_days") +async def get_estimated_budget_by_days_v1( + request: EstimatedBudgetByDaysRequestV1, +) -> UserBudgetResponseV1: + validate_estimate_budget_inputs(days, glm_amount) + + lock_duration_sec = days_to_sec(days) + return estimate_budget(lock_duration_sec, glm_amount) + # current_context = state_context(EpochState.CURRENT) + # current_rewards_service = get_services(EpochState.CURRENT).octant_rewards_service + # current_rewards = current_rewards_service.get_octant_rewards(current_context) + + # future_context = state_context(EpochState.FUTURE) + # future_rewards_service = get_services(EpochState.FUTURE).octant_rewards_service + # future_rewards = future_rewards_service.get_octant_rewards(future_context) + + # return core.estimate_budget( + # current_context, + # future_context, + # current_rewards, + # future_rewards, + # lock_duration_sec, + # glm_amount, + # ) + + +@api.get("/leverage/{epoch_number}") +async def get_rewards_leverage_v1( + session: GetSession, + epochs_contracts: GetEpochsContracts, + epoch_number: int, +) -> RewardsLeverageResponseV1: + epoch_state = await get_epoch_state(session, epochs_contracts, epoch_number) + + if epoch_state > EpochState.PENDING: + raise NotImplementedForGivenEpochState() + + if epoch_state == EpochState.FINALIZED: + # We are in pending epoch, so we need to get the leverage from the pending epoch + + allocations_sum = await sum_allocations_by_epoch(session, epoch_number) + + # allocations_sum = database.allocations.get_alloc_sum_by_epoch( + # context.epoch_details.epoch_num + # ) + + finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) + # finalized_snapshot = database.finalized_epoch_snapshot.get_by_epoch( + # context.epoch_details.epoch_num + # ) + matched_rewards = int(finalized_snapshot.matched_rewards) + + return RewardsLeverageResponseV1( + leverage=matched_rewards / allocations_sum if allocations_sum else 0 + ) + + # return context.epoch_settings.project.rewards.leverage.calculate_leverage( + # matched_rewards, allocations_sum + # ) + # def calculate_leverage(self, matched_rewards: int, total_allocated: int) -> float: + # return matched_rewards / total_allocated if total_allocated else 0 + + if epoch_state <= EpochState.PENDING: + allocations_sum = await sum_allocations_by_epoch(session, epoch_number) + # allocations_sum = database.allocations.get_alloc_sum_by_epoch( + # context.epoch_details.epoch_num + # ) + matched_rewards = self.get_matched_rewards(context) + + return RewardsLeverageResponseV1( + leverage=matched_rewards / allocations_sum if allocations_sum else 0 + ) + + # return context.epoch_settings.project.rewards.leverage.calculate_leverage( + # matched_rewards, allocations_sum + # ) + # def calculate_leverage(self, matched_rewards: int, total_allocated: int) -> float: + # return matched_rewards / total_allocated if total_allocated else 0 + + +@api.get("/merkle_tree/{epoch_number}") +async def get_rewards_merkle_tree_v1( + session: GetSession, + epoch_number: int, +) -> RewardsMerkleTreeResponseV1: + """ + Returns the rewards merkle tree for a given epoch. + """ + + return await get_rewards_merkle_tree_for_epoch(session, epoch_number) @api.get("/projects/estimated") -async def get_estimated_project_rewards( +async def get_estimated_project_rewards_v1( project_rewards_estimator: GetProjectRewardsEstimator, -) -> EstimatedProjectRewardsResponse: +) -> EstimatedProjectRewardsResponseV1: """ Returns foreach project current allocation sum and estimated matched rewards. @@ -17,6 +381,110 @@ async def get_estimated_project_rewards( estimated_funding = await project_rewards_estimator.get() - return EstimatedProjectRewardsResponse( + return EstimatedProjectRewardsResponseV1( rewards=[f for f in estimated_funding.project_fundings.values()] ) + + +@api.get("/projects/epoch/{epoch_number}") +async def get_rewards_for_projects_in_epoch_v1( + session: GetSession, + projects_contracts: GetProjectsContracts, + epoch_number: int, +) -> EstimatedProjectRewardsResponseV1: + """ + Returns projects with matched rewards for a given finalized epoch. + + """ + + projects = await projects_contracts.get_project_addresses(epoch_number) + rewards = await get_rewards_for_projects_in_epoch(session, epoch_number, projects) + + return EstimatedProjectRewardsResponseV1( + rewards=[ + ProjectFundingSummaryV1( + address=reward.address, + allocated=int(reward.amount) - int(reward.matched), + matched=int(reward.matched), + ) + for reward in rewards + ] + ) + + +@api.get("/threshold/{epoch_number}") +async def get_rewards_threshold_v1( + epoch_number: int, + projects_allocation_threshold_getter: GetProjectsAllocationThresholdGetter, +) -> ThresholdResponseV1: + threshold = await projects_allocation_threshold_getter.get() + + return ThresholdResponseV1(threshold=threshold) + + +@api.get("/unused/{epoch_number}") +async def get_unused_rewards_v1( + session: GetSession, + epoch_subgraph: GetEpochsSubgraph, + epoch_number: int, +) -> UnusedRewardsResponseV1: + """ + Returns unallocated value and the number of users who didn't use their rewards in an epoch + """ + + ts = await epoch_subgraph.get_epoch_by_number(epoch_number) + + budgets = await get_all_users_budgets_by_epoch(session, epoch_number) + + # budgets = self.user_budgets.get_all_budgets(context) + # budgets = Budget.query.filter_by(epoch=epoch).all() + # return {budget.user.address: int(budget.budget) for budget in budgets} + + donors = await get_donors_for_epoch(session, epoch_number) + # donors = self.allocations.get_all_donors_addresses(context) + # users = User.query.filter( + # User.allocations.any(epoch=epoch_num, deleted_at=None) + # ).all() + # return [u.address for u in users] + + patrons = await get_all_patrons_at_timestamp( + session, ts.finalized_timestamp.datetime() + ) + + excluded_addresses = set(donors + patrons) + + unused_budgets = {budget for budget in budgets if budget not in excluded_addresses} + + return UnusedRewardsResponseV1( + addresses=list(unused_budgets.keys()), value=sum(unused_budgets.values()) + ) + + # patrons = self.patrons_mode.get_all_patrons_addresses(context) + # patrons = self._get_patron_budgets(context.epoch_details, with_budget) + # ts = epoch.finalized_timestamp + # patrons = database.patrons.get_all_patrons_at_timestamp(ts.datetime()) + + # if with_budget: + # all_budgets = database.budgets.get_all_by_epoch(epoch.epoch_num) + # return { + # patron: all_budgets[patron] + # for patron in patrons + # if patron in all_budgets.keys() + # } + # else: + # return {patron: 0 for patron in patrons} + + # return list(patrons.keys()) + + # return get_unused_rewards(budgets, donors, patrons) + + +# def get_unused_rewards( +# budgets: Dict[str, int], donors: List[str], patrons: List[str] +# ) -> Dict[str, int]: +# for donor in donors: +# budgets.pop(donor) +# for patron in patrons: +# budgets.pop(patron) + +# return budgets diff --git a/backend/v2/project_rewards/sablier.py b/backend/v2/project_rewards/sablier.py new file mode 100644 index 0000000000..74f0230a95 --- /dev/null +++ b/backend/v2/project_rewards/sablier.py @@ -0,0 +1,112 @@ + + + +from gql import Client, gql +from gql.transport.aiohttp import AIOHTTPTransport + + +import backoff +from app.infrastructure.sablier.events import SablierStream +from v2.epochs.subgraphs import BackoffParams + +class SablierEventsGenerator: + + def __init__( + self, + url: str, + sender: str, + token_address: str, + backoff_params: BackoffParams | None = None, + ): + self.url = url + self.sender = sender + self.token_address = token_address + + self.gql_client = Client( + transport=AIOHTTPTransport(url=self.url, timeout=2), + fetch_schema_from_transport=False, + ) + + if backoff_params is not None: + backoff_decorator = backoff.on_exception( + backoff.expo, + backoff_params.exception, + max_time=backoff_params.max_time, + giveup=backoff_params.giveup, + ) + + self.gql_client.execute_async = backoff_decorator( + self.gql_client.execute_async + ) + + + async def _fetch_streams(self, query: str, variables: dict) -> list[SablierStream]: + + all_streams = [] + has_more = True + limit = 1000 + skip = 0 + + while has_more: + variables.update({"limit": limit, "skip": skip}) + + # logger.debug(f"[Sablier Subgraph] Querying streams with skip: {skip}") + result = await self.gql_client.execute_async( + gql(query), variable_values=variables + ) + + streams = result.get("streams", []) + + # app.logger.debug(f"[Sablier Subgraph] Received {len(streams)} streams.") + + for stream in streams: + actions = stream.get("actions", []) + final_intact_amount = stream.get("intactAmount", 0) + all_streams.append( + SablierStream(actions=actions, intactAmount=final_intact_amount) + ) + + if len(streams) < limit: + has_more = False + else: + skip += limit + + return all_streams + + async def get_all_streams_history(self) -> list[SablierStream]: + """ + Get all the locks and unlocks in history. + """ + + query = """ + query GetAllEvents($sender: String!, $tokenAddress: String!, $limit: Int!, $skip: Int!) { + streams( + where: { + sender: $sender + asset_: {address: $tokenAddress} + transferable: false + } + first: $limit + skip: $skip + orderBy: timestamp + ) { + id + intactAmount + actions(where: {category_in: [Cancel, Withdraw, Create]}, orderBy: timestamp) { + category + addressA + addressB + amountA + amountB + timestamp + hash + } + } + } + """ + variables = { + "sender": self.sender, + "tokenAddress": self.token_address, + } + + return await self._fetch_streams(query, variables) diff --git a/backend/v2/project_rewards/schemas.py b/backend/v2/project_rewards/schemas.py index 5c53fce910..d2ce4e6433 100644 --- a/backend/v2/project_rewards/schemas.py +++ b/backend/v2/project_rewards/schemas.py @@ -2,7 +2,7 @@ from v2.core.types import Address, BigInteger, OctantModel -class ProjectFundingSummary(OctantModel): +class ProjectFundingSummaryV1(OctantModel): address: Address = Field(..., description="The address of the project") allocated: BigInteger = Field( ..., description="Sum of all allocation amounts for the project" @@ -12,35 +12,89 @@ class ProjectFundingSummary(OctantModel): ) -class EstimatedProjectRewardsResponse(OctantModel): - rewards: list[ProjectFundingSummary] = Field( +class EstimatedProjectRewardsResponseV1(OctantModel): + rewards: list[ProjectFundingSummaryV1] = Field( ..., description="List of project funding summaries" ) -# project_rewards = await project_rewards_estimator.get(pending_epoch_number) -# rewards = [ -# { -# "address": project_address, -# "allocated": str(project_rewards.amounts_by_project[project_address]), -# "matched": str(project_rewards.matched_by_project[project_address]), -# } -# for project_address in project_rewards.amounts_by_project.keys() -# ] - -# @ns.doc( -# description="Returns project rewards with estimated matched rewards for the pending epoch" -# ) -# @ns.response( -# 200, -# "", -# ) -# @ns.route("/projects/estimated") -# class EstimatedProjectRewards(OctantResource): -# @ns.marshal_with(projects_rewards_model) -# def get(self): -# app.logger.debug("Getting project rewards for the pending epoch") -# project_rewards = get_estimated_project_rewards().rewards -# app.logger.debug(f"Project rewards in the pending epoch: {project_rewards}") - -# return {"rewards": project_rewards} +class EstimatedBudgetByEpochRequestV1(OctantModel): + number_of_epochs: int = Field( + ..., description="Number of epochs when GLM are locked", ge=0 + ) + glm_amount: BigInteger = Field( + ..., description="Amount of estimated GLM locked in wei", ge=0 + ) + + +class UserBudgetWithMatchedFundingResponseV1(OctantModel): + budget: BigInteger = Field(..., description="User budget for given epoch in wei.") + matched_funding: BigInteger = Field( + ..., description="Matched funding for given epoch in wei." + ) + + +class EstimatedBudgetByDaysRequestV1(OctantModel): + days: int = Field(..., description="Number of days when GLM are locked") + glm_amount: BigInteger = Field( + ..., description="Amount of estimated GLM locked in wei" + ) + + +class RewardsMerkleTreeLeafV1(OctantModel): + address: Address = Field(..., description="User account or project address") + amount: BigInteger = Field(..., description="Assigned reward") + + +class RewardsMerkleTreeResponseV1(OctantModel): + epoch: int = Field(..., description="Epoch number") + rewards_sum: BigInteger = Field( + ..., description="Sum of assigned rewards for epoch" + ) + root: str = Field(..., description="Merkle Tree root for epoch") + leaves: list[RewardsMerkleTreeLeafV1] = Field( + ..., description="List of Merkle Tree leaves" + ) + leaf_encoding: list[str] = Field(..., description="Merkle tree leaf encoding") + + +class UserBudgetResponseV1(OctantModel): + budget: BigInteger = Field(..., description="User budget for given epoch in wei.") + + +class EpochBudgetItemV1(OctantModel): + address: Address = Field(..., description="User address") + amount: BigInteger = Field(..., description="User budget for given epoch in wei.") + + +class EpochBudgetsResponseV1(OctantModel): + budgets: list[EpochBudgetItemV1] = Field( + ..., description="List of user budgets for given epoch" + ) + + +class UpcomingUserBudgetResponseV1(OctantModel): + upcoming_budget: BigInteger = Field( + ..., description="Calculated upcoming user budget." + ) + + +class ThresholdResponseV1(OctantModel): + threshold: BigInteger = Field( + ..., + description="Threshold, that projects have to pass to be eligible for receiving rewards.", + ) + + +class RewardsLeverageResponseV1(OctantModel): + leverage: float = Field(..., description="Leverage of the allocated funds") + + +class UnusedRewardsResponseV1(OctantModel): + addresses: list[Address] = Field( + ..., + description="List of addresses that neither allocated rewards nor toggled patron mode", + ) + value: BigInteger = Field( + ..., description="Total unused rewards sum in an epoch (WEI)" + ) diff --git a/backend/v2/project_rewards/services.py b/backend/v2/project_rewards/services.py index 76e6b43734..e139ba3e5c 100644 --- a/backend/v2/project_rewards/services.py +++ b/backend/v2/project_rewards/services.py @@ -1,7 +1,13 @@ import asyncio from dataclasses import dataclass +from multiproof import StandardMerkleTree from sqlalchemy.ext.asyncio import AsyncSession +from v2.project_rewards.repositories import get_rewards_for_epoch +from v2.project_rewards.schemas import ( + RewardsMerkleTreeLeafV1, + RewardsMerkleTreeResponseV1, +) from v2.allocations.repositories import get_allocations_with_user_uqs from v2.matched_rewards.services import MatchedRewardsEstimator from v2.project_rewards.capped_quadriatic import ( @@ -35,3 +41,40 @@ async def get(self) -> CappedQuadriaticFunding: allocations=allocations, matched_rewards=matched_rewards, ) + + +async def get_rewards_merkle_tree_for_epoch( + session: AsyncSession, + epoch_number: int, +) -> RewardsMerkleTreeResponseV1 | None: + """ + Get the rewards merkle tree for a given epoch. + """ + + # Merkle tree is based on rewards, so we need to get them first + rewards = await get_rewards_for_epoch(session, epoch_number) + if not rewards: + return None + + # Build the merkle tree (using the leaf encoding) + LEAF_ENCODING: list[str] = ["address", "uint256"] + mt = StandardMerkleTree.of( + [[leaf.address, int(leaf.amount)] for leaf in rewards], + LEAF_ENCODING, + ) + + # Build response model + leaves = [ + RewardsMerkleTreeLeafV1(address=leaf.value[0], amount=leaf.value[1]) + for leaf in mt.values + ] + + rewards_sum = sum(leaf.amount for leaf in leaves) + + return RewardsMerkleTreeResponseV1( + epoch=epoch_number, + rewards_sum=rewards_sum, + root=mt.root, + leaves=leaves, + leaf_encoding=LEAF_ENCODING, + ) diff --git a/backend/v2/project_rewards/user_events.py b/backend/v2/project_rewards/user_events.py new file mode 100644 index 0000000000..08b8a9bf51 --- /dev/null +++ b/backend/v2/project_rewards/user_events.py @@ -0,0 +1,203 @@ + + +from itertools import groupby +from operator import attrgetter +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from typing import Dict + +from app.engine.octant_rewards import OctantRewardsSettings +from app.engine.user.budget.with_ppf import UserBudgetWithPPF +from app.engine.user.effective_deposit import DepositEvent, EventType, UserDeposit, UserEffectiveDepositPayload +from app.engine.user.effective_deposit.weighted_average.default_with_sablier_timebox import DefaultWeightedAverageWithSablierTimebox +from app.infrastructure.database.models import Deposit +from app.infrastructure.graphql.locks import get_locks_by_timestamp_range +from app.infrastructure.graphql.unlocks import get_unlocks_by_timestamp_range +from app.modules.common.sablier_events_mapper import FlattenStrategy, flatten_sablier_events, process_to_locks_and_unlocks +from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO +from app.modules.octant_rewards.core import calculate_rewards +from app.modules.snapshots.pending.core import calculate_user_budgets +from app.modules.user.budgets.core import get_upcoming_budget +from app.modules.user.events_generator.core import unify_deposit_balances + + +async def get_all_deposit_events_for_epoch( + session: AsyncSession, + epoch_number: int, +) -> dict[str, list[DepositEvent]]: + + results = await session.scalars( + select(Deposit) + .options(selectinload(Deposit.user)) + .where(Deposit.epoch == epoch_number) + ) + + return { + result.user.address: result for result in results + } + + +async def get_all_user_events( + session: AsyncSession, + epoch_number: int, + start_sec: int, + end_sec: int, +) -> dict[str, list[DepositEvent]]: + + # Get all locked amounts for the previous epoch + epoch_start_locked_amounts = await get_all_deposit_events_for_epoch( + session, epoch_number - 1 + ) + epoch_start_events = [ + DepositEvent( + user=user, + type=EventType.LOCK, + timestamp=start_sec, + amount=0, # it is not a deposit in fact + deposit_before=int(deposit.epoch_end_deposit), + ) + for user, deposit in epoch_start_locked_amounts.items() + ] + + sablier = SablierEventsGenerator() + sablier_streams = await sablier.get_all_streams_history() + mapped_streams = process_to_locks_and_unlocks( + sablier_streams, from_timestamp=start_sec, to_timestamp=end_sec + ) + epoch_events = [] + epoch_events += flatten_sablier_events(mapped_streams, FlattenStrategy.ALL) + epoch_events += get_locks_by_timestamp_range(start_sec, end_sec) + epoch_events += get_unlocks_by_timestamp_range(start_sec, end_sec) + + epoch_events = [DepositEvent.from_dict(event) for event in epoch_events] + sorted_events = sorted(epoch_events, key=attrgetter("user", "timestamp")) + + user_events = { + k: list(g) for k, g in groupby(sorted_events, key=attrgetter("user")) + } + + for event in epoch_start_events: + if event.user in user_events: + user_events[event.user].insert(0, event) + else: + user_events[event.user] = [event] + + epoch_start_users = list(map(attrgetter("user"), epoch_start_events)) + for user_address in user_events: + if user_address not in epoch_start_users: + user_events[user_address].insert( + 0, + DepositEvent( + user_address, + EventType.LOCK, + timestamp=start_sec, + amount=0, + deposit_before=0, + ), + ) + + user_events[user_address] = unify_deposit_balances( + user_events[user_address] + ) + + return user_events + + +def calculate_effective_deposits( + start_sec: int, + end_sec: int, + events: Dict[str, list[DepositEvent]], +) -> tuple[list[UserDeposit], int]: + + effective_deposit_calculator = DefaultWeightedAverageWithSablierTimebox() + payload = UserEffectiveDepositPayload( + epoch_start=start_sec, + epoch_end=end_sec, + lock_events_by_addr=events, + ) + + return effective_deposit_calculator.calculate_users_effective_deposits(payload) + + +async def get_octant_rewards( + session: AsyncSession, + epoch_number: int, + start_sec: int, + end_sec: int, +) -> OctantRewardsDTO: + + # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) + # duration_sec = epoch_details.duration + # return estimate_staking_proceeds(duration_sec) + eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) + + events = await get_all_user_events(session, epoch_number, start_sec, end_sec) + user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) + + rewards_settings = OctantRewardsSettings() + + octant_rewards = calculate_rewards( + rewards_settings, eth_proceeds, total_effective_deposit + ) + + ( + locked_ratio, + total_rewards, + vanilla_individual_rewards, + op_cost, + ppf, + community_fund, + ) = ( + octant_rewards.locked_ratio, + octant_rewards.total_rewards, + octant_rewards.vanilla_individual_rewards, + octant_rewards.operational_cost, + octant_rewards.ppf_value, + octant_rewards.community_fund, + ) + + return OctantRewardsDTO( + staking_proceeds=eth_proceeds, + locked_ratio=locked_ratio, + total_effective_deposit=total_effective_deposit, + total_rewards=total_rewards, + vanilla_individual_rewards=vanilla_individual_rewards, + operational_cost=op_cost, + ppf=ppf, + community_fund=community_fund, + ) + + +async def calculate_pending_epoch_snapshot( + session: AsyncSession, + epoch_number: int +) -> PendingSnapshotDTO: + + octant_rewards = await get_octant_rewards(session, epoch_number, start_sec, end_sec) + + events = await get_all_user_events(session, epoch_number, start_sec, end_sec) + user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) + + user_budget_calculator = UserBudgetWithPPF() + user_budgets = calculate_user_budgets( + user_budget_calculator, octant_rewards, user_deposits + ) + + return PendingSnapshotDTO( + rewards=octant_rewards, user_deposits=user_deposits, user_budgets=user_budgets + ) + + +async def get_budget( + session: AsyncSession, + epoch_number: int, + user_address: str +) -> int: + simulated_snapshot = await calculate_pending_epoch_snapshot(session, epoch_number) + + upcoming_budget = get_upcoming_budget( + user_address, simulated_snapshot.user_budgets + ) + + return upcoming_budget diff --git a/backend/v2/projects/dependencies.py b/backend/v2/projects/dependencies.py index 3cf6bfef4e..586229e832 100644 --- a/backend/v2/projects/dependencies.py +++ b/backend/v2/projects/dependencies.py @@ -45,11 +45,15 @@ def get_projects_allocation_threshold_getter( epoch_number: GetOpenAllocationWindowEpochNumber, session: GetSession, projects: GetProjectsContracts, - settings: Annotated[ - ProjectsAllocationThresholdSettings, - Depends(get_projects_allocation_threshold_settings), - ], ) -> ProjectsAllocationThresholdGetter: + project_count_multiplier = 2 if epoch_number <= 2 else 1 + return ProjectsAllocationThresholdGetter( - epoch_number, session, projects, settings.project_count_multiplier + epoch_number, session, projects, project_count_multiplier ) + + +GetProjectsAllocationThresholdGetter = Annotated[ + ProjectsAllocationThresholdGetter, + Depends(get_projects_allocation_threshold_getter), +] diff --git a/backend/v2/user_patron_mode/repositories.py b/backend/v2/user_patron_mode/repositories.py index 903660a5ef..2af10af2eb 100644 --- a/backend/v2/user_patron_mode/repositories.py +++ b/backend/v2/user_patron_mode/repositories.py @@ -4,13 +4,14 @@ from sqlalchemy import Numeric, cast, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select -from v2.core.types import Address +from v2.core.types import Address, BigInteger from v2.users.repositories import get_user_by_address +from v2.core.transformers import transform_to_checksum_address async def get_all_patrons_at_timestamp( session: AsyncSession, dt: datetime -) -> list[str]: +) -> list[Address]: """ Get all the user addresses that at given timestamp have patron_mode_enabled=True. """ @@ -27,7 +28,7 @@ async def get_all_patrons_at_timestamp( ) ) - return [row[0] for row in results.all()] + return [transform_to_checksum_address(row[0]) for row in results.all()] async def get_budget_sum_by_users_addresses_and_epoch( @@ -87,6 +88,23 @@ async def get_budget_by_user_address_and_epoch( return int(budget) +async def get_all_users_budgets_by_epoch( + session: AsyncSession, epoch_number: int +) -> dict[Address, BigInteger]: + """ + Get all budgets for a given epoch. + """ + results = await session.execute( + select(Budget.budget, User.address) + .join(Budget.user) + .where(Budget.epoch == epoch_number) + ) + return { + transform_to_checksum_address(row.address): int(row.budget) + for row in results.all() + } + + async def user_is_patron_with_budget( session: AsyncSession, user_address: Address, From 707e0de581c9fe1ecdab0da1556e2b9346eaf996 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 14 Jan 2025 22:08:51 +0100 Subject: [PATCH 02/14] wip: All things are working 1:1 prod. Now let's make it a bit nicer and testable --- backend/app/context/epoch/block_range.py | 6 +- backend/app/modules/common/time.py | 5 +- backend/v2/core/contracts.py | 1 + backend/v2/deposits/dependencies.py | 19 +- backend/v2/deposits/repositories.py | 127 +++++ backend/v2/epochs/contracts.py | 2 +- backend/v2/epochs/dependencies.py | 3 +- backend/v2/epochs/subgraphs.py | 62 +++ backend/v2/glms/contracts.py | 6 +- backend/v2/glms/dependencies.py | 14 + backend/v2/project_rewards/dependencies.py | 4 +- backend/v2/project_rewards/router.py | 515 ++++++++++++++---- backend/v2/project_rewards/user_events.py | 193 +++---- backend/v2/sablier/__init__.py | 0 backend/v2/sablier/dependencies.py | 54 ++ .../sablier.py => sablier/subgraphs.py} | 29 +- backend/v2/user_patron_mode/repositories.py | 1 + 17 files changed, 784 insertions(+), 257 deletions(-) create mode 100644 backend/v2/deposits/repositories.py create mode 100644 backend/v2/sablier/__init__.py create mode 100644 backend/v2/sablier/dependencies.py rename backend/v2/{project_rewards/sablier.py => sablier/subgraphs.py} (79%) diff --git a/backend/app/context/epoch/block_range.py b/backend/app/context/epoch/block_range.py index 44806c88af..20af7a5111 100644 --- a/backend/app/context/epoch/block_range.py +++ b/backend/app/context/epoch/block_range.py @@ -12,7 +12,9 @@ def get_blocks_range( if not with_block_range: return None, None - start_block = get_block_num_from_ts(start_sec) if start_sec <= now_sec else None - end_block = get_block_num_from_ts(end_sec) if end_sec <= now_sec else None + # start_block = get_block_num_from_ts(start_sec) if start_sec <= now_sec else None + # end_block = get_block_num_from_ts(end_sec) if end_sec <= now_sec else None + start_block = None + end_block = None return start_block, end_block diff --git a/backend/app/modules/common/time.py b/backend/app/modules/common/time.py index a7738c7a8f..7080afb08a 100644 --- a/backend/app/modules/common/time.py +++ b/backend/app/modules/common/time.py @@ -21,7 +21,10 @@ def timestamp_s(self) -> float: return self.timestamp_us() / 10**6 def datetime(self) -> DateTime: - return DateTime.fromtimestamp(self.timestamp_s()) + # Make sure the timestamp is in UTC and not local + utc_timestamp = DateTime.fromtimestamp(self.timestamp_s(), timezone.utc) + # Remove timezone info + return utc_timestamp.replace(tzinfo=None) def to_isoformat(self): return self.datetime().isoformat() diff --git a/backend/v2/core/contracts.py b/backend/v2/core/contracts.py index e73c01d134..39b66f5b85 100644 --- a/backend/v2/core/contracts.py +++ b/backend/v2/core/contracts.py @@ -8,4 +8,5 @@ class SmartContract: def __init__(self, w3: AsyncWeb3, abi: ABI, address: ChecksumAddress) -> None: self.abi = abi self.w3 = w3 + self.address = address self.contract: AsyncContract = w3.eth.contract(address=address, abi=abi) diff --git a/backend/v2/deposits/dependencies.py b/backend/v2/deposits/dependencies.py index cbe9a36500..7691f73f31 100644 --- a/backend/v2/deposits/dependencies.py +++ b/backend/v2/deposits/dependencies.py @@ -1,7 +1,10 @@ from typing import Annotated from fastapi import Depends -from v2.core.dependencies import OctantSettings, Web3 +from v2.deposits.repositories import DepositEventsRepository +from v2.epochs.dependencies import GetEpochsSubgraph +from v2.sablier.dependencies import GetSablierSubgraph +from v2.core.dependencies import GetSession, OctantSettings, Web3 from v2.deposits.contracts import DEPOSITS_ABI, DepositsContracts @@ -17,3 +20,17 @@ def get_deposits_contracts( w3: Web3, settings: Annotated[DepositsSettings, Depends(get_deposits_settings)] ) -> DepositsContracts: return DepositsContracts(w3, DEPOSITS_ABI, settings.deposits_contract_address) # type: ignore[arg-type] + + + +def get_deposit_events_repository( + session: GetSession, + epochs_subgraph: GetEpochsSubgraph, + sublier_subgraph: GetSablierSubgraph, +) -> DepositEventsRepository: + return DepositEventsRepository(session, epochs_subgraph, sublier_subgraph) + + +# Annotated dependencies +GetDepositsContracts = Annotated[DepositsContracts, Depends(get_deposits_contracts)] +GetDepositEventsRepository = Annotated[DepositEventsRepository, Depends(get_deposit_events_repository)] \ No newline at end of file diff --git a/backend/v2/deposits/repositories.py b/backend/v2/deposits/repositories.py new file mode 100644 index 0000000000..1191997e9f --- /dev/null +++ b/backend/v2/deposits/repositories.py @@ -0,0 +1,127 @@ + + +from itertools import groupby +from operator import attrgetter +import os +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from typing import Dict + +from app.engine.octant_rewards import OctantRewardsSettings +from app.engine.user.budget.with_ppf import UserBudgetWithPPF +from app.engine.user.effective_deposit import DepositEvent, EventType, UserDeposit, UserEffectiveDepositPayload +from app.engine.user.effective_deposit.weighted_average.default_with_sablier_timebox import DefaultWeightedAverageWithSablierTimebox +from app.infrastructure.database.models import Deposit +from app.infrastructure.graphql.locks import get_locks_by_timestamp_range +from app.infrastructure.graphql.unlocks import get_unlocks_by_timestamp_range +from app.modules.common.sablier_events_mapper import FlattenStrategy, flatten_sablier_events, process_to_locks_and_unlocks +from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO +from app.modules.octant_rewards.core import calculate_rewards +from app.modules.snapshots.pending.core import calculate_user_budgets +from app.modules.user.budgets.core import get_upcoming_budget +from app.modules.user.events_generator.core import unify_deposit_balances +from app.modules.staking.proceeds.core import estimate_staking_proceeds +from app.constants import SABLIER_SENDER_ADDRESS_SEPOLIA, SABLIER_TOKEN_ADDRESS_SEPOLIA, ZERO_ADDRESS +from app.infrastructure import SubgraphEndpoints +from v2.epochs.subgraphs import EpochsSubgraph +from v2.sablier.subgraphs import SablierSubgraph + + + +async def get_all_deposit_events_for_epoch( + session: AsyncSession, + epoch_number: int, +) -> dict[str, list[DepositEvent]]: + + results = await session.scalars( + select(Deposit) + .options(selectinload(Deposit.user)) + .where(Deposit.epoch == epoch_number) + ) + + return { + result.user.address: result for result in results + } + + + + +class DepositEventsRepository: + def __init__( + self, + session: AsyncSession, + epochs_subgraph: EpochsSubgraph, + sablier_subgraph: SablierSubgraph + ): + self.session = session + self.epochs_subgraph = epochs_subgraph + self.sablier_subgraph = sablier_subgraph + + async def get_all_users_events( + self, + epoch_number: int, + start_sec: int, + end_sec: int, + ) -> dict[str, list[DepositEvent]]: + """ + Returns all user events (LOCK, UNLOCK) for a given epoch. + """ + + # Get all locked amounts for the previous epoch + epoch_start_locked_amounts = await get_all_deposit_events_for_epoch( + self.session, epoch_number - 1 + ) + epoch_start_events = [ + DepositEvent( + user=user, + type=EventType.LOCK, + timestamp=start_sec, + amount=0, # it is not a deposit in fact + deposit_before=int(deposit.epoch_end_deposit), + ) + for user, deposit in epoch_start_locked_amounts.items() + ] + + sablier_streams = await self.sablier_subgraph.get_all_streams_history() + mapped_streams = process_to_locks_and_unlocks( + sablier_streams, from_timestamp=start_sec, to_timestamp=end_sec + ) + # print("Mapped streams", mapped_streams) + epoch_events = [] + epoch_events += flatten_sablier_events(mapped_streams, FlattenStrategy.ALL) + epoch_events += await self.epochs_subgraph.fetch_locks_by_timestamp_range(start_sec, end_sec) + epoch_events += await self.epochs_subgraph.fetch_unlocks_by_timestamp_range(start_sec, end_sec) + + epoch_events = [DepositEvent.from_dict(event) for event in epoch_events] + sorted_events = sorted(epoch_events, key=attrgetter("user", "timestamp")) + + user_events = { + k: list(g) for k, g in groupby(sorted_events, key=attrgetter("user")) + } + + for event in epoch_start_events: + if event.user in user_events: + user_events[event.user].insert(0, event) + else: + user_events[event.user] = [event] + + epoch_start_users = list(map(attrgetter("user"), epoch_start_events)) + for user_address in user_events: + if user_address not in epoch_start_users: + user_events[user_address].insert( + 0, + DepositEvent( + user_address, + EventType.LOCK, + timestamp=start_sec, + amount=0, + deposit_before=0, + ), + ) + + user_events[user_address] = unify_deposit_balances( + user_events[user_address] + ) + + return user_events diff --git a/backend/v2/epochs/contracts.py b/backend/v2/epochs/contracts.py index 8ef263bc96..ed45d7cf36 100644 --- a/backend/v2/epochs/contracts.py +++ b/backend/v2/epochs/contracts.py @@ -49,7 +49,7 @@ async def get_epoch_duration(self) -> int: logging.debug("[Epochs contract] Checking epoch duration") return await self.contract.functions.getEpochDuration().call() - async def get_future_epoch_props(self) -> Dict: + async def get_future_epoch_props(self) -> list[int]: logging.debug("[Epochs contract] Getting epoch props index") index = await self.contract.functions.epochPropsIndex().call() logging.debug("[Epochs contract] Getting next epoch props") diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py index da4495df6d..7015fa0393 100644 --- a/backend/v2/epochs/dependencies.py +++ b/backend/v2/epochs/dependencies.py @@ -89,7 +89,8 @@ async def get_rewards_rate(epoch_number: int) -> float: async def get_epoch_state( session: GetSession, epochs_contracts: GetEpochsContracts, epoch_number: int ) -> EpochState: - current_epoch_number = await get_current_epoch(epoch_number) + + current_epoch_number = await epochs_contracts.get_current_epoch() if epoch_number > current_epoch_number: return EpochState.FUTURE diff --git a/backend/v2/epochs/subgraphs.py b/backend/v2/epochs/subgraphs.py index 9c446ff684..41f8c36773 100644 --- a/backend/v2/epochs/subgraphs.py +++ b/backend/v2/epochs/subgraphs.py @@ -5,6 +5,8 @@ from pydantic import TypeAdapter import backoff from app import exceptions +from app.infrastructure.graphql.locks import LockEvent +from app.infrastructure.graphql.unlocks import UnlockEvent from v2.core.types import OctantModel from v2.core.exceptions import EpochsNotFound from app.context.epoch.details import EpochDetails @@ -76,6 +78,66 @@ def __init__( self.gql_client.execute_async ) + async def fetch_locks_by_timestamp_range(self, from_ts: int, to_ts: int) -> list[LockEvent]: + """ + Get locks by timestamp range. + """ + query = gql( + """ + query GetLocks($fromTimestamp: Int!, $toTimestamp: Int!) { + lockeds( + first: 1000, + skip: 0, + orderBy: timestamp + where: {timestamp_gte: $fromTimestamp, timestamp_lt: $toTimestamp} + ) { + __typename + depositBefore + amount + timestamp + user + transactionHash + } + } + """ + ) + variables = { + "fromTimestamp": from_ts, + "toTimestamp": to_ts, + } + response = await self.gql_client.execute_async(query, variable_values=variables) + return response["lockeds"] + + async def fetch_unlocks_by_timestamp_range(self, from_ts: int, to_ts: int) -> list[UnlockEvent]: + """ + Get unlocks by timestamp range. + """ + query = gql( + """ + query GetUnlocks($fromTimestamp: Int!, $toTimestamp: Int!) { + unlockeds( + first: 1000, + skip: 0, + orderBy: timestamp + where: {timestamp_gte: $fromTimestamp, timestamp_lt: $toTimestamp} + ) { + __typename + depositBefore + amount + timestamp + user + transactionHash + } + } + """ + ) + variables = { + "fromTimestamp": from_ts, + "toTimestamp": to_ts, + } + response = await self.gql_client.execute_async(query, variable_values=variables) + return response["unlockeds"] + async def fetch_epoch_by_number(self, epoch_number: int) -> EpochSubgraphItem: """Get EpochDetails from the subgraph for a given epoch number.""" query = gql( diff --git a/backend/v2/glms/contracts.py b/backend/v2/glms/contracts.py index f5e9b26273..a324013f30 100644 --- a/backend/v2/glms/contracts.py +++ b/backend/v2/glms/contracts.py @@ -40,10 +40,10 @@ async def approve(self, owner: AddressKey, benefactor_address, wad: int): benefactor_address, wad ).build_transaction({"from": owner.address, "nonce": nonce}) signed_tx = self.w3.eth.account.sign_transaction(transaction, owner.key) - return self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + return await self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) - # def balance_of(self, owner: str) -> int: - # return self.contract.functions.balanceOf(owner).call() + async def balance_of(self, owner: str) -> int: + return await self.contract.functions.balanceOf(owner).call() ERC20_ABI = [ diff --git a/backend/v2/glms/dependencies.py b/backend/v2/glms/dependencies.py index 44debdc9ce..a97750c09c 100644 --- a/backend/v2/glms/dependencies.py +++ b/backend/v2/glms/dependencies.py @@ -1,6 +1,9 @@ +from __future__ import annotations from typing import Annotated from fastapi import Depends +from v2.deposits.contracts import DepositsContracts +from v2.deposits.dependencies import GetDepositsContracts from v2.core.dependencies import OctantSettings, Web3 from v2.glms.contracts import ERC20_ABI, GLMContracts @@ -17,3 +20,14 @@ def get_glm_contracts( w3: Web3, settings: Annotated[GLMSettings, Depends(get_glm_settings)] ) -> GLMContracts: return GLMContracts(w3, ERC20_ABI, settings.glm_contract_address) # type: ignore[arg-type] + + +async def get_glm_balance_of_deposits( + glm_contracts: GetGLMContracts, + deposits_contracts: GetDepositsContracts, +) -> int: + return await glm_contracts.balance_of(deposits_contracts.address) + + +GetGLMContracts = Annotated[GLMContracts, Depends(get_glm_contracts)] +GetGLMBalanceOfDeposits = Annotated[int, Depends(get_glm_balance_of_deposits)] \ No newline at end of file diff --git a/backend/v2/project_rewards/dependencies.py b/backend/v2/project_rewards/dependencies.py index fad325071e..98cbe24200 100644 --- a/backend/v2/project_rewards/dependencies.py +++ b/backend/v2/project_rewards/dependencies.py @@ -1,7 +1,8 @@ from typing import Annotated from fastapi import Depends -from v2.core.dependencies import GetSession +from v2.sablier.subgraphs import SablierSubgraph +from v2.core.dependencies import GetSession, OctantSettings from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator from v2.project_rewards.services import ProjectRewardsEstimator @@ -21,7 +22,6 @@ async def get_project_rewards_estimator( epoch_number=epoch_number, ) - GetProjectRewardsEstimator = Annotated[ ProjectRewardsEstimator, Depends(get_project_rewards_estimator), diff --git a/backend/v2/project_rewards/router.py b/backend/v2/project_rewards/router.py index 48f1a258b1..7379959983 100644 --- a/backend/v2/project_rewards/router.py +++ b/backend/v2/project_rewards/router.py @@ -1,6 +1,23 @@ -from fastapi import APIRouter +from datetime import datetime, time, timezone +import os +from fastapi import APIRouter, Response, status +import json from app.context.epoch_state import EpochState from app.exceptions import NotImplementedForGivenEpochState +from app.constants import SABLIER_SENDER_ADDRESS_SEPOLIA, SABLIER_TOKEN_ADDRESS_SEPOLIA, ZERO_ADDRESS +from app.infrastructure import SubgraphEndpoints +from app.engine.user.budget import UserBudgetPayload +from app.engine.user.budget.with_ppf import UserBudgetWithPPF +from app.engine.octant_rewards import OctantRewardsSettings +from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO +from app.modules.octant_rewards.core import calculate_rewards +from app.modules.snapshots.pending.core import calculate_user_budgets +from app.modules.staking.proceeds.core import estimate_staking_proceeds +from v2.deposits.dependencies import GetDepositEventsRepository +from v2.sablier.dependencies import GetSablierSubgraph +from v2.glms.dependencies import GetGLMBalanceOfDeposits, GetGLMContracts +from v2.sablier.subgraphs import SablierSubgraph +from v2.project_rewards.user_events import calculate_effective_deposits, calculate_pending_epoch_snapshot, simulate_user_events from v2.allocations.repositories import ( get_donors_for_epoch, sum_allocations_by_epoch, @@ -49,9 +66,11 @@ async def get_user_budget_for_epoch_v1( session: GetSession, user_address: Address, epoch_number: int, -) -> UserBudgetResponseV1: + response: Response, +) -> UserBudgetResponseV1 | None: """ - Returns user's rewards budget available to allocate for given epoch + Returns user's rewards budget available to allocate for given epoch. + Returns 204 No Content if user does not have budget for given epoch. """ budget = await get_budget_by_user_address_and_epoch( session, @@ -59,23 +78,134 @@ async def get_user_budget_for_epoch_v1( epoch_number, ) - # Q: Should we return 0 or raise exception when user has no budget for the epoch? + if not budget: + response.status_code = status.HTTP_204_NO_CONTENT + return None - return UserBudgetResponseV1(budget=budget or 0) + return UserBudgetResponseV1(budget=budget) @api.get("/budget/{user_address}/upcoming") async def get_user_budget_for_upcoming_epoch_v1( - + session: GetSession, + epoch_contracts: GetEpochsContracts, + epoch_subgraph: GetEpochsSubgraph, + deposit_events: GetDepositEventsRepository, user_address: Address, ) -> UpcomingUserBudgetResponseV1: """ Returns the upcoming user budget based on if allocation happened now. """ + + epoch_number = await epoch_contracts.get_current_epoch() + epoch_details = await epoch_subgraph.fetch_epoch_by_number(epoch_number) + + # We SIMULATE the epoch end as if it ended now + epoch_end = int(datetime.now(timezone.utc).timestamp()) + epoch_start = epoch_details.fromTs + + # pending_snapshot = await calculate_pending_epoch_snapshot( + # deposit_events, + # epoch_number, + # epoch_start, + # epoch_end + # ) +# async def calculate_pending_epoch_snapshot( +# deposit_events: DepositEventsRepository, +# epoch_number: int, +# epoch_start: int, +# epoch_end: int, +# ) -> PendingSnapshotDTO: + + # Get octant rewards + # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) + # duration_sec = epoch_details.duration + # return estimate_staking_proceeds(duration_sec) + # eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) + eth_proceeds = estimate_staking_proceeds(epoch_end - epoch_start) + + events = await deposit_events.get_all_users_events( + epoch_number, + epoch_start, + epoch_end + ) + user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) + + # total_effective_deposit = 155654569757136462439580980 + rewards_settings = OctantRewardsSettings() + octant_rewards = calculate_rewards( + rewards_settings, eth_proceeds, total_effective_deposit + ) + rewards = OctantRewardsDTO( + staking_proceeds=eth_proceeds, + locked_ratio=octant_rewards.locked_ratio, + total_effective_deposit=total_effective_deposit, + total_rewards=octant_rewards.total_rewards, + vanilla_individual_rewards=octant_rewards.vanilla_individual_rewards, + operational_cost=octant_rewards.operational_cost, + ppf=octant_rewards.ppf_value, + community_fund=octant_rewards.community_fund, + ) + + # events = await get_all_user_events( + # session, + # epochs_subgraph, + # sablier, + # epoch_number, + # epoch_start, + # epoch_end + # ) + # user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) + + user_budget_calculator = UserBudgetWithPPF() + user_budgets = calculate_user_budgets( + user_budget_calculator, rewards, user_deposits + ) + # pending_snapshot = PendingSnapshotDTO( + # rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets + # ) + # return PendingSnapshotDTO( + # rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets + # ) + + +# Getting unlocks in timestamp range 1728835200 - 1735308472 + # upcoming_budget = pending_snapshot.user_budgets.get(user_address) + + # print("Pending snapshot", pending_snapshot) + + user_budget = next( + (budget.budget for budget in user_budgets if budget.user_address == user_address), + 0 # Default value if user not found + ) + + # if not user_budget: + # return UpcomingUserBudgetResponseV1(upcoming_budget=0) + + return UpcomingUserBudgetResponseV1(upcoming_budget=user_budget) + print("I'm here") + # TODO we need to handle snapshots here unfortunatelly - upcoming_budget = await get_upcoming_user_budget(user_address) + # upcoming_budget = await get_upcoming_user_budget(user_address) + + # context = state_context(EpochState.SIMULATED, with_block_range=True) + # service: UpcomingUserBudgets = get_services(EpochState.CURRENT).user_budgets_service + # return service.get_budget(context, user_address) + + # def get_budget(self, context: Context, user_address: str) -> int: + # simulated_snapshot = ( + # self.simulated_pending_snapshot_service.simulate_pending_epoch_snapshot( + # context + # ) + # ) + # upcoming_budget = core.get_upcoming_budget( + # user_address, simulated_snapshot.user_budgets + # ) + + # return upcoming_budget + # user_deposits = CalculatedUserDeposits( # events_generator=DbAndGraphEventsGenerator() @@ -207,92 +337,228 @@ async def get_epoch_budgets_v1( @api.post("/estimated_budget") async def get_estimated_budget_v1( + session: GetSession, + epoch_contracts: GetEpochsContracts, + glm_balance: GetGLMBalanceOfDeposits, request: EstimatedBudgetByEpochRequestV1, ) -> UserBudgetWithMatchedFundingResponseV1: - # leverage = octant_rewards_controller.get_last_finalized_epoch_leverage() - # context = state_context(EpochState.FINALIZED) - # service = get_services(context.epoch_state).octant_rewards_service - - # return service.get_leverage(context) - # allocations_sum = database.allocations.get_alloc_sum_by_epoch( - # context.epoch_details.epoch_num - # ) - # finalized_snapshot = database.finalized_epoch_snapshot.get_by_epoch( - # context.epoch_details.epoch_num - # ) - # matched_rewards = int(finalized_snapshot.matched_rewards) - - # return context.epoch_settings.project.rewards.leverage.calculate_leverage( - # matched_rewards, allocations_sum - # ) - # Leverage: - # return matched_rewards / total_allocated if total_allocated else 0 - - # epochs_budget = budget_controller.estimate_epochs_budget(no_epochs, glm_amount) - # validate_estimate_budget_by_epochs_inputs(no_epochs, glm_amount) - - # future_context = state_context(EpochState.FUTURE) - # future_rewards_service = get_services(EpochState.FUTURE).octant_rewards_service - # future_rewards = future_rewards_service.get_octant_rewards(future_context) - - # def _get_future_epoch_details(epoch_num: int) -> EpochDetails: - # epoch_details = epochs.get_future_epoch_props() - # start = epoch_details[2] - # duration = epoch_details[3] - # decision_window = epoch_details[4] - # return EpochDetails( - # epoch_num=epoch_num, - # start=start, - # duration=duration, - # decision_window=decision_window, - # remaining_sec=duration, - # ) + + # leverage + epoch_number = await epoch_contracts.get_finalized_epoch() + + # Calculate leverage based on the last finalized epoch + allocations_sum = await sum_allocations_by_epoch(session, epoch_number) + finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) + matched_rewards = int(finalized_snapshot.matched_rewards) + + leverage = matched_rewards / allocations_sum if allocations_sum else 0 + + future_epoch = await epoch_contracts.get_future_epoch_props() + print("Future epoch", future_epoch) + # start_sec = future_epoch[2] + start_sec = future_epoch[2] + # duration = future_epoch[3] + duration = future_epoch[3] + end_sec = start_sec + duration + # decision_window = future_epoch[4] + decision_window = future_epoch[4] + + # Estimate staking proceeds + eth_proceeds = estimate_staking_proceeds(duration) + total_effective_deposit = glm_balance + + # total_effective_deposit = 155654569757136462439580980 + rewards_settings = OctantRewardsSettings() + octant_rewards = calculate_rewards( + rewards_settings, eth_proceeds, total_effective_deposit + ) + future_rewards = OctantRewardsDTO( + staking_proceeds=eth_proceeds, + locked_ratio=octant_rewards.locked_ratio, + total_effective_deposit=total_effective_deposit, + total_rewards=octant_rewards.total_rewards, + vanilla_individual_rewards=octant_rewards.vanilla_individual_rewards, + operational_cost=octant_rewards.operational_cost, + ppf=octant_rewards.ppf_value, + community_fund=octant_rewards.community_fund, + ) - # return FutureServices( - # octant_rewards_service=CalculatedOctantRewards( - # staking_proceeds=EstimatedStakingProceeds(), - # effective_deposits=ContractBalanceUserDeposits(), - # ), - # projects_metadata_service=StaticProjectsMetadataService(), - # projects_details_service=StaticProjectsDetailsService(), - # ) - # future_rewards = await get_octant_rewards(session, epoch_number, start_sec, end_sec) + # Simulate user events as if they deposited given amount of GLMs + events = { + ZERO_ADDRESS: simulate_user_events( + end_sec, + duration, + duration, + request.glm_amount + ) + } + user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) + + # print("total_effective_deposit", total_effective_deposit) + # print("User deposits", user_deposits) + effective_deposit = ( + user_deposits[0].effective_deposit if user_deposits else 0 + ) + + # print("Effective deposit", effective_deposit) + # print("Total effective deposit", future_rewards.total_effective_deposit) + # print("Vanilla individual rewards", future_rewards.vanilla_individual_rewards) + # print("PPF", future_rewards.ppf) + + budget_calculator = UserBudgetWithPPF() + epoch_budget = budget_calculator.calculate_budget( + UserBudgetPayload( + user_effective_deposit=effective_deposit, + total_effective_deposit=future_rewards.total_effective_deposit, + vanilla_individual_rewards=future_rewards.vanilla_individual_rewards, + ppf=future_rewards.ppf, + ) + ) + epochs_budget = request.number_of_epochs * epoch_budget + matching_fund = epochs_budget * leverage - # epoch_duration = future_context.epoch_details.duration_sec - # return no_epochs * core.estimate_epoch_budget( - # future_context, future_rewards, epoch_duration, glm_amount - # ) + return UserBudgetWithMatchedFundingResponseV1( + budget=epochs_budget, + matched_funding=matching_fund + ) - matching_fund = budget_controller.get_matching_fund(epochs_budget, leverage) - # def get_matching_fund(budget: int, leverage: float) -> int: - # return core.calculate_matching_fund(budget, leverage) - # def calculate_matching_fund(budget: int, leverage: float) -> int: - # return int(budget * leverage) - return EstimatedRewardsDTO( - estimated_budget=epochs_budget, leverage=leverage, matching_fund=matching_fund +def estimate_epoch_budget( + start_sec: int, + end_sec: int, + remaining_sec: int, + lock_duration: int, + glm_amount: int, + rewards: OctantRewardsDTO +) -> int: + events = { + ZERO_ADDRESS: simulate_user_events( + end_sec, + lock_duration, + remaining_sec, + glm_amount + ) + } + user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) + + # print("total_effective_deposit", total_effective_deposit) + # print("User deposits", user_deposits) + effective_deposit = ( + user_deposits[0].effective_deposit if user_deposits else 0 + ) + + # print("Effective deposit", effective_deposit) + # print("Total effective deposit", future_rewards.total_effective_deposit) + # print("Vanilla individual rewards", future_rewards.vanilla_individual_rewards) + # print("PPF", future_rewards.ppf) + + budget_calculator = UserBudgetWithPPF() + return budget_calculator.calculate_budget( + UserBudgetPayload( + user_effective_deposit=effective_deposit, + total_effective_deposit=rewards.total_effective_deposit, + vanilla_individual_rewards=rewards.vanilla_individual_rewards, + ppf=rewards.ppf, + ) ) @api.post("/estimated_budget/by_days") async def get_estimated_budget_by_days_v1( + epoch_contracts: GetEpochsContracts, + epoch_subgraph: GetEpochsSubgraph, + deposit_events: GetDepositEventsRepository, + glm_balance: GetGLMBalanceOfDeposits, request: EstimatedBudgetByDaysRequestV1, ) -> UserBudgetResponseV1: - validate_estimate_budget_inputs(days, glm_amount) + # validate_estimate_budget_inputs(days, glm_amount) + + lock_duration_sec = request.days * 86400 # 24hours * 60minutes * 60seconds + + current_epoch_number = await epoch_contracts.get_current_epoch() + current_epoch_details = await epoch_subgraph.fetch_epoch_by_number(current_epoch_number) + current_epoch_start = current_epoch_details.fromTs + current_epoch_duration = current_epoch_details.duration + current_epoch_remaining = current_epoch_start + current_epoch_duration - int(datetime.now().timestamp()) + + # CURRENT EPOCH REWARDS + current_eth_proceeds = estimate_staking_proceeds(current_epoch_duration) + current_events = await deposit_events.get_all_users_events( + current_epoch_number, + current_epoch_start, + current_epoch_start + current_epoch_duration + ) + user_deposits, total_effective_deposit = calculate_effective_deposits(current_epoch_start, current_epoch_start + current_epoch_duration, current_events) - lock_duration_sec = days_to_sec(days) - return estimate_budget(lock_duration_sec, glm_amount) + current_rewards_settings = OctantRewardsSettings() + current_octant_rewards = calculate_rewards( + current_rewards_settings, current_eth_proceeds, total_effective_deposit + ) + current_rewards = OctantRewardsDTO( + staking_proceeds=current_eth_proceeds, + locked_ratio=current_octant_rewards.locked_ratio, + total_effective_deposit=total_effective_deposit, + total_rewards=current_octant_rewards.total_rewards, + vanilla_individual_rewards=current_octant_rewards.vanilla_individual_rewards, + operational_cost=current_octant_rewards.operational_cost, + ppf=current_octant_rewards.ppf_value, + community_fund=current_octant_rewards.community_fund, + ) + + + # return estimate_budget(lock_duration_sec, glm_amount) # current_context = state_context(EpochState.CURRENT) # current_rewards_service = get_services(EpochState.CURRENT).octant_rewards_service + # CURRENT + # is_mainnet = compare_blockchain_types(chain_id, ChainTypes.MAINNET) + # octant_rewards = CalculatedOctantRewards( + # staking_proceeds=EstimatedStakingProceeds(), + # effective_deposits=CalculatedUserDeposits( + # events_generator=DbAndGraphEventsGenerator() + # ) + # ) + + # current_rewards = current_rewards_service.get_octant_rewards(current_context) + # FUTURE EPOCH REWARDS + future_epoch_number = await epoch_contracts.get_future_epoch_props() + future_epoch_start = future_epoch_number[2] + future_epoch_duration = future_epoch_number[3] + future_epoch_end = future_epoch_start + future_epoch_duration + future_eth_proceeds = estimate_staking_proceeds(future_epoch_duration) + + future_total_effective_deposit = glm_balance + + future_rewards_settings = OctantRewardsSettings() + future_octant_rewards = calculate_rewards( + future_rewards_settings, future_eth_proceeds, future_total_effective_deposit + ) + future_rewards = OctantRewardsDTO( + staking_proceeds=future_eth_proceeds, + locked_ratio=future_octant_rewards.locked_ratio, + total_effective_deposit=future_total_effective_deposit, + total_rewards=future_octant_rewards.total_rewards, + vanilla_individual_rewards=future_octant_rewards.vanilla_individual_rewards, + operational_cost=future_octant_rewards.operational_cost, + ppf=future_octant_rewards.ppf_value, + community_fund=future_octant_rewards.community_fund, + ) + # future_context = state_context(EpochState.FUTURE) # future_rewards_service = get_services(EpochState.FUTURE).octant_rewards_service + # octant_rewards_service=CalculatedOctantRewards( + # staking_proceeds=EstimatedStakingProceeds(), + # effective_deposits=ContractBalanceUserDeposits(), + # ), + # future_rewards = future_rewards_service.get_octant_rewards(future_context) + + # return core.estimate_budget( # current_context, # future_context, @@ -302,6 +568,46 @@ async def get_estimated_budget_by_days_v1( # glm_amount, # ) + remaining_lock_duration = lock_duration_sec + + budget = estimate_epoch_budget( + current_epoch_start, + current_epoch_start + current_epoch_duration, + current_epoch_remaining, + remaining_lock_duration, + request.glm_amount, + current_rewards, + ) + remaining_lock_duration -= current_epoch_remaining + + if remaining_lock_duration > 0: + full_epochs_num, remaining_future_epoch_sec = divmod( + remaining_lock_duration, future_epoch_duration + ) + budget += full_epochs_num * estimate_epoch_budget( + future_epoch_start, + future_epoch_end, + future_epoch_duration, + future_epoch_duration, + request.glm_amount, + future_rewards, + ) + remaining_lock_duration = remaining_future_epoch_sec + + if remaining_lock_duration > 0: + budget += estimate_epoch_budget( + future_epoch_start, + future_epoch_end, + future_epoch_duration, + remaining_lock_duration, + request.glm_amount, + future_rewards, + ) + + return UserBudgetResponseV1( + budget=budget + ) + @api.get("/leverage/{epoch_number}") async def get_rewards_leverage_v1( @@ -309,8 +615,16 @@ async def get_rewards_leverage_v1( epochs_contracts: GetEpochsContracts, epoch_number: int, ) -> RewardsLeverageResponseV1: + """ + Returns leverage for a given epoch. + + For finalized epochs it returns the leverage based on the data from the finalized snapshot. + + """ + epoch_state = await get_epoch_state(session, epochs_contracts, epoch_number) + print("Epoch state", epoch_state) if epoch_state > EpochState.PENDING: raise NotImplementedForGivenEpochState() @@ -376,7 +690,7 @@ async def get_estimated_project_rewards_v1( """ Returns foreach project current allocation sum and estimated matched rewards. - This endpoint is available only for the pending epoch state. + This endpoint is available only while allocation window is open. """ estimated_funding = await project_rewards_estimator.get() @@ -397,6 +711,7 @@ async def get_rewards_for_projects_in_epoch_v1( """ + # For epoch projects retrieve their rewards projects = await projects_contracts.get_project_addresses(epoch_number) rewards = await get_rewards_for_projects_in_epoch(session, epoch_number, projects) @@ -429,62 +744,26 @@ async def get_unused_rewards_v1( epoch_number: int, ) -> UnusedRewardsResponseV1: """ - Returns unallocated value and the number of users who didn't use their rewards in an epoch - """ + Returns list of users who didn't use their rewards in an epoch and the total sum of all unused rewards. - ts = await epoch_subgraph.get_epoch_by_number(epoch_number) + This is based on available budgets and excluding donors (users who have allocated funds) and patrons. + """ + # All users budgets, donors and patrons for the epoch budgets = await get_all_users_budgets_by_epoch(session, epoch_number) - - # budgets = self.user_budgets.get_all_budgets(context) - # budgets = Budget.query.filter_by(epoch=epoch).all() - # return {budget.user.address: int(budget.budget) for budget in budgets} - donors = await get_donors_for_epoch(session, epoch_number) - # donors = self.allocations.get_all_donors_addresses(context) - # users = User.query.filter( - # User.allocations.any(epoch=epoch_num, deleted_at=None) - # ).all() - # return [u.address for u in users] - + epoch_details = await epoch_subgraph.get_epoch_by_number(epoch_number) patrons = await get_all_patrons_at_timestamp( - session, ts.finalized_timestamp.datetime() + session, epoch_details.finalized_timestamp.datetime() ) - excluded_addresses = set(donors + patrons) - - unused_budgets = {budget for budget in budgets if budget not in excluded_addresses} + # Exclude donors and patrons from the list of users who didn't use their rewards + unused_budgets = { + address: budget for address, budget in budgets.items() + if address not in set(donors + patrons) + } return UnusedRewardsResponseV1( - addresses=list(unused_budgets.keys()), value=sum(unused_budgets.values()) + addresses=sorted(list(unused_budgets.keys())), + value=sum(unused_budgets.values()), ) - - # patrons = self.patrons_mode.get_all_patrons_addresses(context) - # patrons = self._get_patron_budgets(context.epoch_details, with_budget) - # ts = epoch.finalized_timestamp - # patrons = database.patrons.get_all_patrons_at_timestamp(ts.datetime()) - - # if with_budget: - # all_budgets = database.budgets.get_all_by_epoch(epoch.epoch_num) - # return { - # patron: all_budgets[patron] - # for patron in patrons - # if patron in all_budgets.keys() - # } - # else: - # return {patron: 0 for patron in patrons} - - # return list(patrons.keys()) - - # return get_unused_rewards(budgets, donors, patrons) - - -# def get_unused_rewards( -# budgets: Dict[str, int], donors: List[str], patrons: List[str] -# ) -> Dict[str, int]: -# for donor in donors: -# budgets.pop(donor) -# for patron in patrons: -# budgets.pop(patron) - -# return budgets diff --git a/backend/v2/project_rewards/user_events.py b/backend/v2/project_rewards/user_events.py index 08b8a9bf51..c44bbf77f7 100644 --- a/backend/v2/project_rewards/user_events.py +++ b/backend/v2/project_rewards/user_events.py @@ -2,6 +2,7 @@ from itertools import groupby from operator import attrgetter +import os from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -20,89 +21,17 @@ from app.modules.snapshots.pending.core import calculate_user_budgets from app.modules.user.budgets.core import get_upcoming_budget from app.modules.user.events_generator.core import unify_deposit_balances +from app.modules.staking.proceeds.core import estimate_staking_proceeds +from app.constants import SABLIER_SENDER_ADDRESS_SEPOLIA, SABLIER_TOKEN_ADDRESS_SEPOLIA, ZERO_ADDRESS +from app.infrastructure import SubgraphEndpoints +from v2.deposits.repositories import DepositEventsRepository +from v2.epochs.subgraphs import EpochsSubgraph +from v2.sablier.subgraphs import SablierSubgraph -async def get_all_deposit_events_for_epoch( - session: AsyncSession, - epoch_number: int, -) -> dict[str, list[DepositEvent]]: - - results = await session.scalars( - select(Deposit) - .options(selectinload(Deposit.user)) - .where(Deposit.epoch == epoch_number) - ) - return { - result.user.address: result for result in results - } -async def get_all_user_events( - session: AsyncSession, - epoch_number: int, - start_sec: int, - end_sec: int, -) -> dict[str, list[DepositEvent]]: - - # Get all locked amounts for the previous epoch - epoch_start_locked_amounts = await get_all_deposit_events_for_epoch( - session, epoch_number - 1 - ) - epoch_start_events = [ - DepositEvent( - user=user, - type=EventType.LOCK, - timestamp=start_sec, - amount=0, # it is not a deposit in fact - deposit_before=int(deposit.epoch_end_deposit), - ) - for user, deposit in epoch_start_locked_amounts.items() - ] - - sablier = SablierEventsGenerator() - sablier_streams = await sablier.get_all_streams_history() - mapped_streams = process_to_locks_and_unlocks( - sablier_streams, from_timestamp=start_sec, to_timestamp=end_sec - ) - epoch_events = [] - epoch_events += flatten_sablier_events(mapped_streams, FlattenStrategy.ALL) - epoch_events += get_locks_by_timestamp_range(start_sec, end_sec) - epoch_events += get_unlocks_by_timestamp_range(start_sec, end_sec) - - epoch_events = [DepositEvent.from_dict(event) for event in epoch_events] - sorted_events = sorted(epoch_events, key=attrgetter("user", "timestamp")) - - user_events = { - k: list(g) for k, g in groupby(sorted_events, key=attrgetter("user")) - } - - for event in epoch_start_events: - if event.user in user_events: - user_events[event.user].insert(0, event) - else: - user_events[event.user] = [event] - - epoch_start_users = list(map(attrgetter("user"), epoch_start_events)) - for user_address in user_events: - if user_address not in epoch_start_users: - user_events[user_address].insert( - 0, - DepositEvent( - user_address, - EventType.LOCK, - timestamp=start_sec, - amount=0, - deposit_before=0, - ), - ) - - user_events[user_address] = unify_deposit_balances( - user_events[user_address] - ) - - return user_events - def calculate_effective_deposits( start_sec: int, @@ -110,6 +39,7 @@ def calculate_effective_deposits( events: Dict[str, list[DepositEvent]], ) -> tuple[list[UserDeposit], int]: + # TODO: We can do this better and nicer effective_deposit_calculator = DefaultWeightedAverageWithSablierTimebox() payload = UserEffectiveDepositPayload( epoch_start=start_sec, @@ -120,72 +50,62 @@ def calculate_effective_deposits( return effective_deposit_calculator.calculate_users_effective_deposits(payload) -async def get_octant_rewards( - session: AsyncSession, +async def calculate_pending_epoch_snapshot( + deposit_events: DepositEventsRepository, epoch_number: int, - start_sec: int, - end_sec: int, -) -> OctantRewardsDTO: + epoch_start: int, + epoch_end: int, +) -> PendingSnapshotDTO: - # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) + # Get octant rewards + # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) # duration_sec = epoch_details.duration # return estimate_staking_proceeds(duration_sec) - eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) + # eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) + eth_proceeds = estimate_staking_proceeds(epoch_end - epoch_start) - events = await get_all_user_events(session, epoch_number, start_sec, end_sec) - user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) + events = await deposit_events.get_all_users_events( + epoch_number, + epoch_start, + epoch_end + ) + user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) + # total_effective_deposit = 155654569757136462439580980 rewards_settings = OctantRewardsSettings() octant_rewards = calculate_rewards( rewards_settings, eth_proceeds, total_effective_deposit ) - ( - locked_ratio, - total_rewards, - vanilla_individual_rewards, - op_cost, - ppf, - community_fund, - ) = ( - octant_rewards.locked_ratio, - octant_rewards.total_rewards, - octant_rewards.vanilla_individual_rewards, - octant_rewards.operational_cost, - octant_rewards.ppf_value, - octant_rewards.community_fund, - ) - - return OctantRewardsDTO( + rewards = OctantRewardsDTO( staking_proceeds=eth_proceeds, - locked_ratio=locked_ratio, + locked_ratio=octant_rewards.locked_ratio, total_effective_deposit=total_effective_deposit, - total_rewards=total_rewards, - vanilla_individual_rewards=vanilla_individual_rewards, - operational_cost=op_cost, - ppf=ppf, - community_fund=community_fund, + total_rewards=octant_rewards.total_rewards, + vanilla_individual_rewards=octant_rewards.vanilla_individual_rewards, + operational_cost=octant_rewards.operational_cost, + ppf=octant_rewards.ppf_value, + community_fund=octant_rewards.community_fund, ) - -async def calculate_pending_epoch_snapshot( - session: AsyncSession, - epoch_number: int -) -> PendingSnapshotDTO: - - octant_rewards = await get_octant_rewards(session, epoch_number, start_sec, end_sec) - - events = await get_all_user_events(session, epoch_number, start_sec, end_sec) - user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) + # events = await get_all_user_events( + # session, + # epochs_subgraph, + # sablier, + # epoch_number, + # epoch_start, + # epoch_end + # ) + # user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) user_budget_calculator = UserBudgetWithPPF() user_budgets = calculate_user_budgets( - user_budget_calculator, octant_rewards, user_deposits + user_budget_calculator, rewards, user_deposits ) return PendingSnapshotDTO( - rewards=octant_rewards, user_deposits=user_deposits, user_budgets=user_budgets + rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets ) @@ -201,3 +121,32 @@ async def get_budget( ) return upcoming_budget + + +def simulate_user_events( + end_sec: int, + lock_duration: int, + remaining_sec: int, + glm_amount: int +) -> list[DepositEvent]: + + user_events = [ + DepositEvent( + user=ZERO_ADDRESS, + type=EventType.LOCK, + timestamp=end_sec - remaining_sec, + amount=glm_amount, + deposit_before=0, + ) + ] + if lock_duration < remaining_sec: + user_events.append( + DepositEvent( + user=ZERO_ADDRESS, + type=EventType.UNLOCK, + timestamp=end_sec - remaining_sec + lock_duration, + amount=glm_amount, + deposit_before=glm_amount, + ) + ) + return user_events \ No newline at end of file diff --git a/backend/v2/sablier/__init__.py b/backend/v2/sablier/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/sablier/dependencies.py b/backend/v2/sablier/dependencies.py new file mode 100644 index 0000000000..0f9ffb4fec --- /dev/null +++ b/backend/v2/sablier/dependencies.py @@ -0,0 +1,54 @@ +from functools import lru_cache +from typing import Annotated + +from fastapi import Depends +from pydantic import Field +from pydantic_settings import SettingsConfigDict +from app.shared.blockchain_types import ChainTypes +from v2.sablier.subgraphs import SablierSubgraph +from v2.core.dependencies import GetChainSettings, GetSession, OctantSettings +from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.project_rewards.services import ProjectRewardsEstimator +from v2.projects.dependencies import GetProjectsContracts + + +class SablierSubgraphSettings(OctantSettings): + # For mainnet + sablier_mainnet_subgraph_url: str + sablier_token_address: str = Field( + default="0x7DD9c5Cba05E151C895FDe1CF355C9A1D5DA6429", + alias="GLM_TOKEN_ADDRESS", + ) + sablier_sender_address: str = "" + # For sepolia + sablier_sepolia_subgraph_url: str + sablier_token_address_sepolia: str = "0x71432dd1ae7db41706ee6a22148446087bdd0906" + sablier_sender_address_sepolia: str = "0xf86fD85672683c220709B9ED80bAD7a51800206a" + + +@lru_cache(maxsize=1) +def get_sablier_subgraph(chain_settings: GetChainSettings) -> SablierSubgraph: + """ + Based on the chain type (mainnet or sepolia), return the appropriate SablierSubgraph. + """ + + sablier_settings = SablierSubgraphSettings() + + if chain_settings.chain_id == ChainTypes.MAINNET: + return SablierSubgraph( + sablier_settings.sablier_mainnet_subgraph_url, + sablier_settings.sablier_sender_address, + sablier_settings.sablier_token_address, + ) + + return SablierSubgraph( + sablier_settings.sablier_sepolia_subgraph_url, + sablier_settings.sablier_sender_address_sepolia, + sablier_settings.sablier_token_address_sepolia, + ) + +GetSablierSubgraph = Annotated[ + SablierSubgraph, + Depends(get_sablier_subgraph), +] diff --git a/backend/v2/project_rewards/sablier.py b/backend/v2/sablier/subgraphs.py similarity index 79% rename from backend/v2/project_rewards/sablier.py rename to backend/v2/sablier/subgraphs.py index 74f0230a95..fa2e0357fe 100644 --- a/backend/v2/project_rewards/sablier.py +++ b/backend/v2/sablier/subgraphs.py @@ -1,15 +1,19 @@ - - - +import logging +import os +from flask import json from gql import Client, gql from gql.transport.aiohttp import AIOHTTPTransport +from gql.client import log as requests_logger import backoff from app.infrastructure.sablier.events import SablierStream from v2.epochs.subgraphs import BackoffParams -class SablierEventsGenerator: +from gql.transport.aiohttp import log as requests_logger +requests_logger.setLevel(logging.WARNING) + +class SablierSubgraph: def __init__( self, @@ -18,12 +22,17 @@ def __init__( token_address: str, backoff_params: BackoffParams | None = None, ): + + requests_logger.setLevel(logging.WARNING) self.url = url self.sender = sender self.token_address = token_address self.gql_client = Client( - transport=AIOHTTPTransport(url=self.url, timeout=2), + transport=AIOHTTPTransport( + url=self.url, + timeout=2, + ), fetch_schema_from_transport=False, ) @@ -109,4 +118,12 @@ async def get_all_streams_history(self) -> list[SablierStream]: "tokenAddress": self.token_address, } - return await self._fetch_streams(query, variables) + # Cache into file to avoid rate limiting + if not os.path.exists("sablier_streams.json"): + streams = await self._fetch_streams(query, variables) + with open("sablier_streams.json", "w") as f: + json.dump(streams, f) + else: + with open("sablier_streams.json", "r") as f: + streams = json.load(f) + return streams diff --git a/backend/v2/user_patron_mode/repositories.py b/backend/v2/user_patron_mode/repositories.py index 2af10af2eb..422cc06832 100644 --- a/backend/v2/user_patron_mode/repositories.py +++ b/backend/v2/user_patron_mode/repositories.py @@ -98,6 +98,7 @@ async def get_all_users_budgets_by_epoch( select(Budget.budget, User.address) .join(Budget.user) .where(Budget.epoch == epoch_number) + .order_by(User.address) ) return { transform_to_checksum_address(row.address): int(row.budget) From b266640cb546979b1db59d3c4554175c32932373 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Thu, 16 Jan 2025 01:27:17 +0100 Subject: [PATCH 03/14] Cleaned up the code and removed complexity for easier reading --- backend/app/settings.py | 5 +- backend/v2/core/dependencies.py | 12 + backend/v2/deposits/dependencies.py | 25 +- backend/v2/deposits/repositories.py | 62 +- backend/v2/epochs/contracts.py | 2 +- backend/v2/epochs/dependencies.py | 1 - backend/v2/epochs/subgraphs.py | 12 +- backend/v2/glms/dependencies.py | 3 +- backend/v2/project_rewards/dependencies.py | 4 +- backend/v2/project_rewards/repositories.py | 2 +- backend/v2/project_rewards/router.py | 649 ++++++--------------- backend/v2/project_rewards/schemas.py | 4 + backend/v2/project_rewards/services.py | 85 +++ backend/v2/project_rewards/user_events.py | 72 +-- backend/v2/projects/contracts.py | 9 +- backend/v2/projects/dependencies.py | 15 +- backend/v2/sablier/dependencies.py | 8 +- backend/v2/sablier/subgraphs.py | 10 +- 18 files changed, 394 insertions(+), 586 deletions(-) diff --git a/backend/app/settings.py b/backend/app/settings.py index eb2fe7bc99..c1b5f1656f 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -117,7 +117,10 @@ class DevConfig(Config): CHAIN_ID = int(os.getenv("CHAIN_ID", 1337)) # Put the db file in project root DB_PATH = os.path.join(Config.PROJECT_ROOT, DB_NAME) - SQLALCHEMY_DATABASE_URI = f"sqlite:///{DB_PATH}" + # SQLALCHEMY_DATABASE_URI = f"sqlite:///{DB_PATH}" + SQLALCHEMY_DATABASE_URI = ( + f"postgresql://postgres:mysecretpassword@localhost:5433/postgres" + ) SUBGRAPH_RETRY_TIMEOUT_SEC = 2 X_REAL_IP_REQUIRED = parse_bool(os.getenv("X_REAL_IP_REQUIRED", "false")) CACHE_TYPE = "SimpleCache" diff --git a/backend/v2/core/dependencies.py b/backend/v2/core/dependencies.py index 4c238dcf0f..afd3dbe5ba 100644 --- a/backend/v2/core/dependencies.py +++ b/backend/v2/core/dependencies.py @@ -1,4 +1,5 @@ from functools import lru_cache +import time from typing import Annotated, AsyncGenerator from fastapi import Depends @@ -8,6 +9,8 @@ from web3 import AsyncHTTPProvider, AsyncWeb3 from web3.middleware import async_geth_poa_middleware +from app.shared.blockchain_types import ChainTypes + class OctantSettings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore", frozen=True) @@ -107,6 +110,10 @@ class ChainSettings(OctantSettings): description="The chain id to use for the signature verification.", ) + @property + def is_mainnet(self) -> bool: + return self.chain_id == ChainTypes.MAINNET + def get_chain_settings() -> ChainSettings: return ChainSettings() @@ -127,7 +134,12 @@ def get_socketio_settings() -> SocketioSettings: return SocketioSettings() # type: ignore[call-arg] +def get_current_timestamp() -> int: + return int(time.time()) + + GetSocketioSettings = Annotated[SocketioSettings, Depends(get_socketio_settings)] GetChainSettings = Annotated[ChainSettings, Depends(get_chain_settings)] Web3 = Annotated[AsyncWeb3, Depends(get_w3)] GetSession = Annotated[AsyncSession, Depends(get_db_session)] +GetCurrentTimestamp = Annotated[int, Depends(get_current_timestamp)] diff --git a/backend/v2/deposits/dependencies.py b/backend/v2/deposits/dependencies.py index 7691f73f31..c0c3509492 100644 --- a/backend/v2/deposits/dependencies.py +++ b/backend/v2/deposits/dependencies.py @@ -1,10 +1,14 @@ from typing import Annotated from fastapi import Depends +from app.constants import ( + SABLIER_UNLOCK_GRACE_PERIOD_24_HRS, + TEST_SABLIER_UNLOCK_GRACE_PERIOD_15_MIN, +) from v2.deposits.repositories import DepositEventsRepository from v2.epochs.dependencies import GetEpochsSubgraph from v2.sablier.dependencies import GetSablierSubgraph -from v2.core.dependencies import GetSession, OctantSettings, Web3 +from v2.core.dependencies import GetChainSettings, GetSession, OctantSettings, Web3 from v2.deposits.contracts import DEPOSITS_ABI, DepositsContracts @@ -22,15 +26,28 @@ def get_deposits_contracts( return DepositsContracts(w3, DEPOSITS_ABI, settings.deposits_contract_address) # type: ignore[arg-type] - def get_deposit_events_repository( session: GetSession, epochs_subgraph: GetEpochsSubgraph, sublier_subgraph: GetSablierSubgraph, + chain_settings: GetChainSettings, ) -> DepositEventsRepository: - return DepositEventsRepository(session, epochs_subgraph, sublier_subgraph) + sablier_unlock_grace_period = ( + SABLIER_UNLOCK_GRACE_PERIOD_24_HRS + if chain_settings.is_mainnet + else TEST_SABLIER_UNLOCK_GRACE_PERIOD_15_MIN + ) + + return DepositEventsRepository( + session, + epochs_subgraph, + sublier_subgraph, + sablier_unlock_grace_period, + ) # Annotated dependencies GetDepositsContracts = Annotated[DepositsContracts, Depends(get_deposits_contracts)] -GetDepositEventsRepository = Annotated[DepositEventsRepository, Depends(get_deposit_events_repository)] \ No newline at end of file +GetDepositEventsRepository = Annotated[ + DepositEventsRepository, Depends(get_deposit_events_repository) +] diff --git a/backend/v2/deposits/repositories.py b/backend/v2/deposits/repositories.py index 1191997e9f..c9ad6bdc91 100644 --- a/backend/v2/deposits/repositories.py +++ b/backend/v2/deposits/repositories.py @@ -1,39 +1,30 @@ - - from itertools import groupby from operator import attrgetter -import os from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from typing import Dict -from app.engine.octant_rewards import OctantRewardsSettings -from app.engine.user.budget.with_ppf import UserBudgetWithPPF -from app.engine.user.effective_deposit import DepositEvent, EventType, UserDeposit, UserEffectiveDepositPayload -from app.engine.user.effective_deposit.weighted_average.default_with_sablier_timebox import DefaultWeightedAverageWithSablierTimebox +from app.engine.user.effective_deposit import ( + DepositEvent, + EventType, +) from app.infrastructure.database.models import Deposit -from app.infrastructure.graphql.locks import get_locks_by_timestamp_range -from app.infrastructure.graphql.unlocks import get_unlocks_by_timestamp_range -from app.modules.common.sablier_events_mapper import FlattenStrategy, flatten_sablier_events, process_to_locks_and_unlocks -from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO -from app.modules.octant_rewards.core import calculate_rewards -from app.modules.snapshots.pending.core import calculate_user_budgets -from app.modules.user.budgets.core import get_upcoming_budget +from app.modules.common.sablier_events_mapper import ( + FlattenStrategy, + flatten_sablier_events, + process_to_locks_and_unlocks, +) from app.modules.user.events_generator.core import unify_deposit_balances -from app.modules.staking.proceeds.core import estimate_staking_proceeds -from app.constants import SABLIER_SENDER_ADDRESS_SEPOLIA, SABLIER_TOKEN_ADDRESS_SEPOLIA, ZERO_ADDRESS -from app.infrastructure import SubgraphEndpoints +from v2.core.types import Address +from v2.core.transformers import transform_to_checksum_address from v2.epochs.subgraphs import EpochsSubgraph from v2.sablier.subgraphs import SablierSubgraph - async def get_all_deposit_events_for_epoch( session: AsyncSession, epoch_number: int, -) -> dict[str, list[DepositEvent]]: - +) -> dict[Address, Deposit]: results = await session.scalars( select(Deposit) .options(selectinload(Deposit.user)) @@ -41,22 +32,22 @@ async def get_all_deposit_events_for_epoch( ) return { - result.user.address: result for result in results + transform_to_checksum_address(result.user.address): result for result in results } - - class DepositEventsRepository: def __init__( - self, - session: AsyncSession, - epochs_subgraph: EpochsSubgraph, - sablier_subgraph: SablierSubgraph - ): + self, + session: AsyncSession, + epochs_subgraph: EpochsSubgraph, + sablier_subgraph: SablierSubgraph, + sablier_unlock_grace_period: int, + ): self.session = session self.epochs_subgraph = epochs_subgraph self.sablier_subgraph = sablier_subgraph + self.sablier_unlock_grace_period = sablier_unlock_grace_period async def get_all_users_events( self, @@ -67,7 +58,7 @@ async def get_all_users_events( """ Returns all user events (LOCK, UNLOCK) for a given epoch. """ - + # Get all locked amounts for the previous epoch epoch_start_locked_amounts = await get_all_deposit_events_for_epoch( self.session, epoch_number - 1 @@ -90,8 +81,12 @@ async def get_all_users_events( # print("Mapped streams", mapped_streams) epoch_events = [] epoch_events += flatten_sablier_events(mapped_streams, FlattenStrategy.ALL) - epoch_events += await self.epochs_subgraph.fetch_locks_by_timestamp_range(start_sec, end_sec) - epoch_events += await self.epochs_subgraph.fetch_unlocks_by_timestamp_range(start_sec, end_sec) + epoch_events += await self.epochs_subgraph.fetch_locks_by_timestamp_range( + start_sec, end_sec + ) + epoch_events += await self.epochs_subgraph.fetch_unlocks_by_timestamp_range( + start_sec, end_sec + ) epoch_events = [DepositEvent.from_dict(event) for event in epoch_events] sorted_events = sorted(epoch_events, key=attrgetter("user", "timestamp")) @@ -121,7 +116,8 @@ async def get_all_users_events( ) user_events[user_address] = unify_deposit_balances( - user_events[user_address] + user_events[user_address], + self.sablier_unlock_grace_period, ) return user_events diff --git a/backend/v2/epochs/contracts.py b/backend/v2/epochs/contracts.py index ed45d7cf36..48c76c6fa7 100644 --- a/backend/v2/epochs/contracts.py +++ b/backend/v2/epochs/contracts.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Optional +from typing import Optional from v2.core.contracts import SmartContract from web3 import exceptions diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py index 7015fa0393..f7ff2cc88e 100644 --- a/backend/v2/epochs/dependencies.py +++ b/backend/v2/epochs/dependencies.py @@ -89,7 +89,6 @@ async def get_rewards_rate(epoch_number: int) -> float: async def get_epoch_state( session: GetSession, epochs_contracts: GetEpochsContracts, epoch_number: int ) -> EpochState: - current_epoch_number = await epochs_contracts.get_current_epoch() if epoch_number > current_epoch_number: return EpochState.FUTURE diff --git a/backend/v2/epochs/subgraphs.py b/backend/v2/epochs/subgraphs.py index 41f8c36773..621389af55 100644 --- a/backend/v2/epochs/subgraphs.py +++ b/backend/v2/epochs/subgraphs.py @@ -78,7 +78,9 @@ def __init__( self.gql_client.execute_async ) - async def fetch_locks_by_timestamp_range(self, from_ts: int, to_ts: int) -> list[LockEvent]: + async def fetch_locks_by_timestamp_range( + self, from_ts: int, to_ts: int + ) -> list[LockEvent]: """ Get locks by timestamp range. """ @@ -108,7 +110,9 @@ async def fetch_locks_by_timestamp_range(self, from_ts: int, to_ts: int) -> list response = await self.gql_client.execute_async(query, variable_values=variables) return response["lockeds"] - async def fetch_unlocks_by_timestamp_range(self, from_ts: int, to_ts: int) -> list[UnlockEvent]: + async def fetch_unlocks_by_timestamp_range( + self, from_ts: int, to_ts: int + ) -> list[UnlockEvent]: """ Get unlocks by timestamp range. """ @@ -154,9 +158,9 @@ async def fetch_epoch_by_number(self, epoch_number: int) -> EpochSubgraphItem: """ ) variables = {"epochNo": epoch_number} - + response = await self.gql_client.execute_async(query, variable_values=variables) - + data = response["epoches"] if not data: raise exceptions.EpochNotIndexed(epoch_number) diff --git a/backend/v2/glms/dependencies.py b/backend/v2/glms/dependencies.py index a97750c09c..084efb1be4 100644 --- a/backend/v2/glms/dependencies.py +++ b/backend/v2/glms/dependencies.py @@ -2,7 +2,6 @@ from typing import Annotated from fastapi import Depends -from v2.deposits.contracts import DepositsContracts from v2.deposits.dependencies import GetDepositsContracts from v2.core.dependencies import OctantSettings, Web3 from v2.glms.contracts import ERC20_ABI, GLMContracts @@ -30,4 +29,4 @@ async def get_glm_balance_of_deposits( GetGLMContracts = Annotated[GLMContracts, Depends(get_glm_contracts)] -GetGLMBalanceOfDeposits = Annotated[int, Depends(get_glm_balance_of_deposits)] \ No newline at end of file +GetGLMBalanceOfDeposits = Annotated[int, Depends(get_glm_balance_of_deposits)] diff --git a/backend/v2/project_rewards/dependencies.py b/backend/v2/project_rewards/dependencies.py index 98cbe24200..fad325071e 100644 --- a/backend/v2/project_rewards/dependencies.py +++ b/backend/v2/project_rewards/dependencies.py @@ -1,8 +1,7 @@ from typing import Annotated from fastapi import Depends -from v2.sablier.subgraphs import SablierSubgraph -from v2.core.dependencies import GetSession, OctantSettings +from v2.core.dependencies import GetSession from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator from v2.project_rewards.services import ProjectRewardsEstimator @@ -22,6 +21,7 @@ async def get_project_rewards_estimator( epoch_number=epoch_number, ) + GetProjectRewardsEstimator = Annotated[ ProjectRewardsEstimator, Depends(get_project_rewards_estimator), diff --git a/backend/v2/project_rewards/repositories.py b/backend/v2/project_rewards/repositories.py index 359eea93fe..9836d6fecc 100644 --- a/backend/v2/project_rewards/repositories.py +++ b/backend/v2/project_rewards/repositories.py @@ -1,7 +1,7 @@ -from email.headerregistry import Address from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.infrastructure.database.models import Reward +from v2.core.types import Address async def get_rewards_for_epoch( diff --git a/backend/v2/project_rewards/router.py b/backend/v2/project_rewards/router.py index 7379959983..0b9f811b90 100644 --- a/backend/v2/project_rewards/router.py +++ b/backend/v2/project_rewards/router.py @@ -1,23 +1,19 @@ -from datetime import datetime, time, timezone -import os from fastapi import APIRouter, Response, status -import json from app.context.epoch_state import EpochState -from app.exceptions import NotImplementedForGivenEpochState -from app.constants import SABLIER_SENDER_ADDRESS_SEPOLIA, SABLIER_TOKEN_ADDRESS_SEPOLIA, ZERO_ADDRESS -from app.infrastructure import SubgraphEndpoints -from app.engine.user.budget import UserBudgetPayload +from app.exceptions import ( + InvalidEpoch, + MissingSnapshot, + NotImplementedForGivenEpochState, +) from app.engine.user.budget.with_ppf import UserBudgetWithPPF -from app.engine.octant_rewards import OctantRewardsSettings -from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO -from app.modules.octant_rewards.core import calculate_rewards from app.modules.snapshots.pending.core import calculate_user_budgets from app.modules.staking.proceeds.core import estimate_staking_proceeds +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator from v2.deposits.dependencies import GetDepositEventsRepository -from v2.sablier.dependencies import GetSablierSubgraph -from v2.glms.dependencies import GetGLMBalanceOfDeposits, GetGLMContracts -from v2.sablier.subgraphs import SablierSubgraph -from v2.project_rewards.user_events import calculate_effective_deposits, calculate_pending_epoch_snapshot, simulate_user_events +from v2.glms.dependencies import GetGLMBalanceOfDeposits +from v2.project_rewards.user_events import ( + calculate_effective_deposits, +) from v2.allocations.repositories import ( get_donors_for_epoch, sum_allocations_by_epoch, @@ -39,8 +35,13 @@ get_all_users_budgets_by_epoch, get_budget_by_user_address_and_epoch, ) -from v2.core.dependencies import GetSession -from v2.project_rewards.services import get_rewards_merkle_tree_for_epoch +from v2.core.dependencies import GetCurrentTimestamp, GetSession +from v2.project_rewards.services import ( + calculate_octant_rewards, + calculate_user_budget, + get_rewards_merkle_tree_for_epoch, + simulate_user_effective_deposits, +) from v2.project_rewards.dependencies import GetProjectRewardsEstimator from v2.project_rewards.schemas import ( EpochBudgetItemV1, @@ -87,234 +88,67 @@ async def get_user_budget_for_epoch_v1( @api.get("/budget/{user_address}/upcoming") async def get_user_budget_for_upcoming_epoch_v1( - session: GetSession, - epoch_contracts: GetEpochsContracts, - epoch_subgraph: GetEpochsSubgraph, - deposit_events: GetDepositEventsRepository, + # Dependencies + epochs_contracts: GetEpochsContracts, + epochs_subgraph: GetEpochsSubgraph, + deposit_events_repository: GetDepositEventsRepository, + current_timestamp: GetCurrentTimestamp, + # Parameters user_address: Address, ) -> UpcomingUserBudgetResponseV1: """ - Returns the upcoming user budget based on if allocation happened now. + Returns the upcoming user budget based on as-if allocation happened now. + + This is done by simulating the epoch end as if it ended now. + So we simulate the pending snapshot for this epoch end. + - Get current epoch details + - Simulate the epoch end as if it ended now (end_time = now) + - Simulate the pending snapshot for this epoch end + - Estimate staking proceeds + - Calculate total effective deposit based on deposit events + - Calculate octant rewards + - Calculate the user budget for this pending snapshot """ - epoch_number = await epoch_contracts.get_current_epoch() - epoch_details = await epoch_subgraph.fetch_epoch_by_number(epoch_number) - + # Get current epoch details + epoch_number = await epochs_contracts.get_current_epoch() + epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) + # We SIMULATE the epoch end as if it ended now - epoch_end = int(datetime.now(timezone.utc).timestamp()) + epoch_end = current_timestamp epoch_start = epoch_details.fromTs - # pending_snapshot = await calculate_pending_epoch_snapshot( - # deposit_events, - # epoch_number, - # epoch_start, - # epoch_end - # ) -# async def calculate_pending_epoch_snapshot( -# deposit_events: DepositEventsRepository, -# epoch_number: int, -# epoch_start: int, -# epoch_end: int, -# ) -> PendingSnapshotDTO: - - # Get octant rewards - # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) - # duration_sec = epoch_details.duration - # return estimate_staking_proceeds(duration_sec) - # eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) + # BEGIN: Calculate pending snapshot eth_proceeds = estimate_staking_proceeds(epoch_end - epoch_start) - events = await deposit_events.get_all_users_events( - epoch_number, - epoch_start, - epoch_end - ) - user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) - - # total_effective_deposit = 155654569757136462439580980 - rewards_settings = OctantRewardsSettings() - octant_rewards = calculate_rewards( - rewards_settings, eth_proceeds, total_effective_deposit + # Based on all deposit events, calculate effective deposits + events = await deposit_events_repository.get_all_users_events( + epoch_number, epoch_start, epoch_end ) - rewards = OctantRewardsDTO( - staking_proceeds=eth_proceeds, - locked_ratio=octant_rewards.locked_ratio, - total_effective_deposit=total_effective_deposit, - total_rewards=octant_rewards.total_rewards, - vanilla_individual_rewards=octant_rewards.vanilla_individual_rewards, - operational_cost=octant_rewards.operational_cost, - ppf=octant_rewards.ppf_value, - community_fund=octant_rewards.community_fund, + user_deposits, total_effective_deposit = calculate_effective_deposits( + epoch_start, epoch_end, events ) - # events = await get_all_user_events( - # session, - # epochs_subgraph, - # sablier, - # epoch_number, - # epoch_start, - # epoch_end - # ) - # user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) + # Calculate octant rewards + rewards = calculate_octant_rewards(eth_proceeds, total_effective_deposit) + # END: Calculate pending snapshot + + # Calculate user budget user_budget_calculator = UserBudgetWithPPF() user_budgets = calculate_user_budgets( user_budget_calculator, rewards, user_deposits ) - # pending_snapshot = PendingSnapshotDTO( - # rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets - # ) - # return PendingSnapshotDTO( - # rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets - # ) - - -# Getting unlocks in timestamp range 1728835200 - 1735308472 - # upcoming_budget = pending_snapshot.user_budgets.get(user_address) - - # print("Pending snapshot", pending_snapshot) - user_budget = next( - (budget.budget for budget in user_budgets if budget.user_address == user_address), - 0 # Default value if user not found + ( + budget.budget + for budget in user_budgets + if budget.user_address == user_address + ), + 0, # Default value if user not found ) - # if not user_budget: - # return UpcomingUserBudgetResponseV1(upcoming_budget=0) - return UpcomingUserBudgetResponseV1(upcoming_budget=user_budget) - print("I'm here") - - # TODO we need to handle snapshots here unfortunatelly - # upcoming_budget = await get_upcoming_user_budget(user_address) - - - - # context = state_context(EpochState.SIMULATED, with_block_range=True) - # service: UpcomingUserBudgets = get_services(EpochState.CURRENT).user_budgets_service - # return service.get_budget(context, user_address) - - # def get_budget(self, context: Context, user_address: str) -> int: - # simulated_snapshot = ( - # self.simulated_pending_snapshot_service.simulate_pending_epoch_snapshot( - # context - # ) - # ) - # upcoming_budget = core.get_upcoming_budget( - # user_address, simulated_snapshot.user_budgets - # ) - - # return upcoming_budget - - - # user_deposits = CalculatedUserDeposits( - # events_generator=DbAndGraphEventsGenerator() - # ) - # octant_rewards = CalculatedOctantRewards( - # staking_proceeds=EstimatedStakingProceeds(), - # effective_deposits=user_deposits, - # ) - # UpcomingUserBudgets( - # simulated_pending_snapshot_service=SimulatedPendingSnapshots( - # effective_deposits=user_deposits, octant_rewards=octant_rewards - # ) - # ) - - - - # simulated_snapshot = ( - # self.simulated_pending_snapshot_service.simulate_pending_epoch_snapshot( - # context - # ) - # ) - - # def _calculate_pending_epoch_snapshot(self, context: Context) -> PendingSnapshotDTO: - # rewards = self.octant_rewards.get_octant_rewards(context) - - # def get_octant_rewards(self, context: Context) -> OctantRewardsDTO: - # eth_proceeds = self.staking_proceeds.get_staking_proceeds(context) - # def get_staking_proceeds(self, context: Context) -> int: - # return estimate_staking_proceeds(context.epoch_details.duration_sec) - - # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) - # duration_sec = epoch_details.duration - # return estimate_staking_proceeds(duration_sec) - - # total_effective_deposit = self.effective_deposits.get_total_effective_deposit( - # context - # ) - # def get_total_effective_deposit(self, context: Context) -> int: - # events = self.events_generator.get_all_users_events(context) - # _, total = calculate_effective_deposits( - # context.epoch_details, context.epoch_settings, events - # ) - # return total - - - # rewards_settings = context.epoch_settings.octant_rewards - # octant_rewards = calculate_rewards( - # rewards_settings, eth_proceeds, total_effective_deposit - # ) - # ( - # locked_ratio, - # total_rewards, - # vanilla_individual_rewards, - # op_cost, - # ppf, - # community_fund, - # ) = ( - # octant_rewards.locked_ratio, - # octant_rewards.total_rewards, - # octant_rewards.vanilla_individual_rewards, - # octant_rewards.operational_cost, - # octant_rewards.ppf_value, - # octant_rewards.community_fund, - # ) - - # return OctantRewardsDTO( - # staking_proceeds=eth_proceeds, - # locked_ratio=locked_ratio, - # total_effective_deposit=total_effective_deposit, - # total_rewards=total_rewards, - # vanilla_individual_rewards=vanilla_individual_rewards, - # operational_cost=op_cost, - # ppf=ppf, - # community_fund=community_fund, - # ) - - # ( - # user_deposits, - # _, - # ) = self.effective_deposits.get_all_effective_deposits(context) - # user_budgets = calculate_user_budgets( - # context.epoch_settings.user.budget, rewards, user_deposits - # ) - - # return PendingSnapshotDTO( - # rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets - # ) - - # user_budget = next( - # filter( - # lambda budget_info: budget_info.user_address == user_address, - # upcoming_user_budgets, - # ), - # None, - # ) - # if not user_budget: - # return 0 - # return user_budget.budget - - # upcoming_budget = core.get_upcoming_budget( - # user_address, simulated_snapshot.user_budgets - # ) - - # return upcoming_budget - - return UpcomingUserBudgetResponseV1(upcoming_budget=upcoming_budget) - - - @api.get("/budgets/epoch/{epoch_number}") @@ -340,129 +174,61 @@ async def get_estimated_budget_v1( session: GetSession, epoch_contracts: GetEpochsContracts, glm_balance: GetGLMBalanceOfDeposits, + matched_rewards_estimator: GetMatchedRewardsEstimator, request: EstimatedBudgetByEpochRequestV1, ) -> UserBudgetWithMatchedFundingResponseV1: - - # leverage - epoch_number = await epoch_contracts.get_finalized_epoch() - - # Calculate leverage based on the last finalized epoch - allocations_sum = await sum_allocations_by_epoch(session, epoch_number) - finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) - matched_rewards = int(finalized_snapshot.matched_rewards) - - leverage = matched_rewards / allocations_sum if allocations_sum else 0 + """ + Returns the estimated budget if the user deposits GLMs for a given number of FULL epochs. + Each epoch is a 90 day period. + + This is done based on the last finalized snapshot. + - Get the last finalized epoch + - Calculate the leverage based on the last finalized epoch + - Get the future epoch details (start_time, duration, end_time) + - Calculate rewards: + - Estimate staking proceeds + - Assume total effective deposit as total GLMs from contract + - Calculate octant rewards + - Simulate user events as if they deposited given amount of GLMs + - Calculate user budget for this pending snapshot + """ future_epoch = await epoch_contracts.get_future_epoch_props() - print("Future epoch", future_epoch) - # start_sec = future_epoch[2] - start_sec = future_epoch[2] - # duration = future_epoch[3] - duration = future_epoch[3] - end_sec = start_sec + duration - # decision_window = future_epoch[4] - decision_window = future_epoch[4] - - # Estimate staking proceeds - eth_proceeds = estimate_staking_proceeds(duration) + epoch_start = future_epoch[2] + epoch_duration = future_epoch[3] + epoch_end = epoch_start + epoch_duration + epoch_remaining = epoch_duration + + # We interpolate future rewards based on the future epoch + # duration and total effective deposit of the finalized epoch + eth_proceeds = estimate_staking_proceeds(epoch_duration) total_effective_deposit = glm_balance + future_rewards = calculate_octant_rewards(eth_proceeds, total_effective_deposit) - # total_effective_deposit = 155654569757136462439580980 - rewards_settings = OctantRewardsSettings() - octant_rewards = calculate_rewards( - rewards_settings, eth_proceeds, total_effective_deposit + # Simulate budget + user_effective_deposit = simulate_user_effective_deposits( + epoch_start, + epoch_end, + epoch_remaining, + epoch_duration, + request.glm_amount, ) - future_rewards = OctantRewardsDTO( - staking_proceeds=eth_proceeds, - locked_ratio=octant_rewards.locked_ratio, - total_effective_deposit=total_effective_deposit, - total_rewards=octant_rewards.total_rewards, - vanilla_individual_rewards=octant_rewards.vanilla_individual_rewards, - operational_cost=octant_rewards.operational_cost, - ppf=octant_rewards.ppf_value, - community_fund=octant_rewards.community_fund, + user_budget = calculate_user_budget( + user_effective_deposit, + future_rewards, ) + epochs_budget = request.number_of_epochs * user_budget - # Simulate user events as if they deposited given amount of GLMs - events = { - ZERO_ADDRESS: simulate_user_events( - end_sec, - duration, - duration, - request.glm_amount - ) - } - user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) - - # print("total_effective_deposit", total_effective_deposit) - # print("User deposits", user_deposits) - effective_deposit = ( - user_deposits[0].effective_deposit if user_deposits else 0 - ) - - # print("Effective deposit", effective_deposit) - # print("Total effective deposit", future_rewards.total_effective_deposit) - # print("Vanilla individual rewards", future_rewards.vanilla_individual_rewards) - # print("PPF", future_rewards.ppf) - - budget_calculator = UserBudgetWithPPF() - epoch_budget = budget_calculator.calculate_budget( - UserBudgetPayload( - user_effective_deposit=effective_deposit, - total_effective_deposit=future_rewards.total_effective_deposit, - vanilla_individual_rewards=future_rewards.vanilla_individual_rewards, - ppf=future_rewards.ppf, - ) + # Matching fund is calculated based on the last epoch's leverage + epoch_number = await epoch_contracts.get_finalized_epoch() + rewards_leverage = await get_rewards_leverage_v1( + session, epoch_contracts, epoch_number, matched_rewards_estimator ) - epochs_budget = request.number_of_epochs * epoch_budget - - matching_fund = epochs_budget * leverage - return UserBudgetWithMatchedFundingResponseV1( budget=epochs_budget, - matched_funding=matching_fund - ) - - -def estimate_epoch_budget( - start_sec: int, - end_sec: int, - remaining_sec: int, - lock_duration: int, - glm_amount: int, - rewards: OctantRewardsDTO -) -> int: - events = { - ZERO_ADDRESS: simulate_user_events( - end_sec, - lock_duration, - remaining_sec, - glm_amount - ) - } - user_deposits, total_effective_deposit = calculate_effective_deposits(start_sec, end_sec, events) - - # print("total_effective_deposit", total_effective_deposit) - # print("User deposits", user_deposits) - effective_deposit = ( - user_deposits[0].effective_deposit if user_deposits else 0 - ) - - # print("Effective deposit", effective_deposit) - # print("Total effective deposit", future_rewards.total_effective_deposit) - # print("Vanilla individual rewards", future_rewards.vanilla_individual_rewards) - # print("PPF", future_rewards.ppf) - - budget_calculator = UserBudgetWithPPF() - return budget_calculator.calculate_budget( - UserBudgetPayload( - user_effective_deposit=effective_deposit, - total_effective_deposit=rewards.total_effective_deposit, - vanilla_individual_rewards=rewards.vanilla_individual_rewards, - ppf=rewards.ppf, - ) + matched_funding=int(epochs_budget * rewards_leverage.leverage), ) @@ -472,59 +238,66 @@ async def get_estimated_budget_by_days_v1( epoch_subgraph: GetEpochsSubgraph, deposit_events: GetDepositEventsRepository, glm_balance: GetGLMBalanceOfDeposits, + current_timestamp: GetCurrentTimestamp, request: EstimatedBudgetByDaysRequestV1, ) -> UserBudgetResponseV1: - # validate_estimate_budget_inputs(days, glm_amount) + """ + Returns the estimated budget if the user deposits GLMs for a given number of days. + + This is done by first filling up the current epoch and then calculating the remaining budget for the future epochs. + """ - lock_duration_sec = request.days * 86400 # 24hours * 60minutes * 60seconds + remaining_lock_duration = request.lock_duration_sec + # Get the current epoch details current_epoch_number = await epoch_contracts.get_current_epoch() - current_epoch_details = await epoch_subgraph.fetch_epoch_by_number(current_epoch_number) + current_epoch_details = await epoch_subgraph.fetch_epoch_by_number( + current_epoch_number + ) current_epoch_start = current_epoch_details.fromTs current_epoch_duration = current_epoch_details.duration - current_epoch_remaining = current_epoch_start + current_epoch_duration - int(datetime.now().timestamp()) + current_epoch_remaining = ( + current_epoch_start + current_epoch_duration - current_timestamp + ) # CURRENT EPOCH REWARDS current_eth_proceeds = estimate_staking_proceeds(current_epoch_duration) current_events = await deposit_events.get_all_users_events( current_epoch_number, current_epoch_start, - current_epoch_start + current_epoch_duration - ) - user_deposits, total_effective_deposit = calculate_effective_deposits(current_epoch_start, current_epoch_start + current_epoch_duration, current_events) - - current_rewards_settings = OctantRewardsSettings() - current_octant_rewards = calculate_rewards( - current_rewards_settings, current_eth_proceeds, total_effective_deposit + current_epoch_start + current_epoch_duration, ) - current_rewards = OctantRewardsDTO( - staking_proceeds=current_eth_proceeds, - locked_ratio=current_octant_rewards.locked_ratio, - total_effective_deposit=total_effective_deposit, - total_rewards=current_octant_rewards.total_rewards, - vanilla_individual_rewards=current_octant_rewards.vanilla_individual_rewards, - operational_cost=current_octant_rewards.operational_cost, - ppf=current_octant_rewards.ppf_value, - community_fund=current_octant_rewards.community_fund, + _, total_effective_deposit = calculate_effective_deposits( + current_epoch_start, + current_epoch_start + current_epoch_duration, + current_events, ) + current_rewards = calculate_octant_rewards( + current_eth_proceeds, total_effective_deposit + ) - # return estimate_budget(lock_duration_sec, glm_amount) - # current_context = state_context(EpochState.CURRENT) - # current_rewards_service = get_services(EpochState.CURRENT).octant_rewards_service - # CURRENT - # is_mainnet = compare_blockchain_types(chain_id, ChainTypes.MAINNET) - # octant_rewards = CalculatedOctantRewards( - # staking_proceeds=EstimatedStakingProceeds(), - # effective_deposits=CalculatedUserDeposits( - # events_generator=DbAndGraphEventsGenerator() - # ) - # ) - + # Simulate user budget till the end of the current epoch + user_effective_deposit = simulate_user_effective_deposits( + current_epoch_start, + current_epoch_start + current_epoch_duration, + current_epoch_remaining, + remaining_lock_duration, + request.glm_amount, + ) + budget = calculate_user_budget( + user_effective_deposit, + current_rewards, + ) - # current_rewards = current_rewards_service.get_octant_rewards(current_context) + # Subtract the current epoch remaining from the remaining lock duration + # If there's no remaining lock duration, we're done + remaining_lock_duration -= current_epoch_remaining + if remaining_lock_duration <= 0: + return UserBudgetResponseV1(budget=budget) # FUTURE EPOCH REWARDS + # Because we've already filled up the current epoch, we need to calculate based on the future epoch future_epoch_number = await epoch_contracts.get_future_epoch_props() future_epoch_start = future_epoch_number[2] future_epoch_duration = future_epoch_number[3] @@ -532,81 +305,42 @@ async def get_estimated_budget_by_days_v1( future_eth_proceeds = estimate_staking_proceeds(future_epoch_duration) future_total_effective_deposit = glm_balance - - future_rewards_settings = OctantRewardsSettings() - future_octant_rewards = calculate_rewards( - future_rewards_settings, future_eth_proceeds, future_total_effective_deposit + future_rewards = calculate_octant_rewards( + future_eth_proceeds, future_total_effective_deposit ) - future_rewards = OctantRewardsDTO( - staking_proceeds=future_eth_proceeds, - locked_ratio=future_octant_rewards.locked_ratio, - total_effective_deposit=future_total_effective_deposit, - total_rewards=future_octant_rewards.total_rewards, - vanilla_individual_rewards=future_octant_rewards.vanilla_individual_rewards, - operational_cost=future_octant_rewards.operational_cost, - ppf=future_octant_rewards.ppf_value, - community_fund=future_octant_rewards.community_fund, - ) - - # future_context = state_context(EpochState.FUTURE) - # future_rewards_service = get_services(EpochState.FUTURE).octant_rewards_service - # octant_rewards_service=CalculatedOctantRewards( - # staking_proceeds=EstimatedStakingProceeds(), - # effective_deposits=ContractBalanceUserDeposits(), - # ), - - # future_rewards = future_rewards_service.get_octant_rewards(future_context) + # Calculate the number of full future epochs and the remaining lock duration + full_epochs_num, remaining_future_epoch_sec = divmod( + remaining_lock_duration, future_epoch_duration + ) - - # return core.estimate_budget( - # current_context, - # future_context, - # current_rewards, - # future_rewards, - # lock_duration_sec, - # glm_amount, - # ) - - remaining_lock_duration = lock_duration_sec - - budget = estimate_epoch_budget( - current_epoch_start, - current_epoch_start + current_epoch_duration, - current_epoch_remaining, - remaining_lock_duration, + # Simulate user budget for the full future epochs + user_effective_deposit = simulate_user_effective_deposits( + future_epoch_start, + future_epoch_end, + future_epoch_duration, + future_epoch_duration, request.glm_amount, - current_rewards, ) - remaining_lock_duration -= current_epoch_remaining + budget += full_epochs_num * calculate_user_budget( + user_effective_deposit, + future_rewards, + ) - if remaining_lock_duration > 0: - full_epochs_num, remaining_future_epoch_sec = divmod( - remaining_lock_duration, future_epoch_duration - ) - budget += full_epochs_num * estimate_epoch_budget( + if remaining_future_epoch_sec > 0: + user_effective_deposit = simulate_user_effective_deposits( future_epoch_start, future_epoch_end, future_epoch_duration, - future_epoch_duration, + remaining_future_epoch_sec, request.glm_amount, - future_rewards, ) - remaining_lock_duration = remaining_future_epoch_sec - - if remaining_lock_duration > 0: - budget += estimate_epoch_budget( - future_epoch_start, - future_epoch_end, - future_epoch_duration, - remaining_lock_duration, - request.glm_amount, + budget += calculate_user_budget( + user_effective_deposit, future_rewards, ) - return UserBudgetResponseV1( - budget=budget - ) + return UserBudgetResponseV1(budget=budget) @api.get("/leverage/{epoch_number}") @@ -614,61 +348,40 @@ async def get_rewards_leverage_v1( session: GetSession, epochs_contracts: GetEpochsContracts, epoch_number: int, + matched_rewards_estimator: GetMatchedRewardsEstimator, ) -> RewardsLeverageResponseV1: """ Returns leverage for a given epoch. For finalized epochs it returns the leverage based on the data from the finalized snapshot. - + For pending and finalizing epochs it returns the leverage based on the estimated matched rewards of pending epoch. """ + print(f"Getting leverage for epoch {epoch_number}") + epoch_state = await get_epoch_state(session, epochs_contracts, epoch_number) - print("Epoch state", epoch_state) + # This operations only makes sense for finalized, finalizing and pending epochs if epoch_state > EpochState.PENDING: raise NotImplementedForGivenEpochState() + # Figure out the matched rewards if epoch_state == EpochState.FINALIZED: - # We are in pending epoch, so we need to get the leverage from the pending epoch - - allocations_sum = await sum_allocations_by_epoch(session, epoch_number) - - # allocations_sum = database.allocations.get_alloc_sum_by_epoch( - # context.epoch_details.epoch_num - # ) - + # For finalized epoch matched_rewards is just taken from the finalized snapshot finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) - # finalized_snapshot = database.finalized_epoch_snapshot.get_by_epoch( - # context.epoch_details.epoch_num - # ) + if finalized_snapshot is None: + raise MissingSnapshot() matched_rewards = int(finalized_snapshot.matched_rewards) - return RewardsLeverageResponseV1( - leverage=matched_rewards / allocations_sum if allocations_sum else 0 - ) + else: + # For pending or finalizing epoch matched_rewards is estimated based on current state of AW + matched_rewards = await matched_rewards_estimator.get() - # return context.epoch_settings.project.rewards.leverage.calculate_leverage( - # matched_rewards, allocations_sum - # ) - # def calculate_leverage(self, matched_rewards: int, total_allocated: int) -> float: - # return matched_rewards / total_allocated if total_allocated else 0 - - if epoch_state <= EpochState.PENDING: - allocations_sum = await sum_allocations_by_epoch(session, epoch_number) - # allocations_sum = database.allocations.get_alloc_sum_by_epoch( - # context.epoch_details.epoch_num - # ) - matched_rewards = self.get_matched_rewards(context) - - return RewardsLeverageResponseV1( - leverage=matched_rewards / allocations_sum if allocations_sum else 0 - ) + allocations_sum = await sum_allocations_by_epoch(session, epoch_number) - # return context.epoch_settings.project.rewards.leverage.calculate_leverage( - # matched_rewards, allocations_sum - # ) - # def calculate_leverage(self, matched_rewards: int, total_allocated: int) -> float: - # return matched_rewards / total_allocated if total_allocated else 0 + return RewardsLeverageResponseV1( + leverage=matched_rewards / allocations_sum if allocations_sum else 0 + ) @api.get("/merkle_tree/{epoch_number}") @@ -680,7 +393,12 @@ async def get_rewards_merkle_tree_v1( Returns the rewards merkle tree for a given epoch. """ - return await get_rewards_merkle_tree_for_epoch(session, epoch_number) + tree = await get_rewards_merkle_tree_for_epoch(session, epoch_number) + if tree is None: + # TODO: this could be better :) + raise InvalidEpoch() + + return tree @api.get("/projects/estimated") @@ -759,11 +477,12 @@ async def get_unused_rewards_v1( # Exclude donors and patrons from the list of users who didn't use their rewards unused_budgets = { - address: budget for address, budget in budgets.items() + address: budget + for address, budget in budgets.items() if address not in set(donors + patrons) } return UnusedRewardsResponseV1( - addresses=sorted(list(unused_budgets.keys())), + addresses=sorted(list(unused_budgets.keys())), value=sum(unused_budgets.values()), ) diff --git a/backend/v2/project_rewards/schemas.py b/backend/v2/project_rewards/schemas.py index d2ce4e6433..314697e141 100644 --- a/backend/v2/project_rewards/schemas.py +++ b/backend/v2/project_rewards/schemas.py @@ -40,6 +40,10 @@ class EstimatedBudgetByDaysRequestV1(OctantModel): ..., description="Amount of estimated GLM locked in wei" ) + @property + def lock_duration_sec(self) -> int: + return self.days * 86400 # 24hours * 60minutes * 60seconds + class RewardsMerkleTreeLeafV1(OctantModel): address: Address = Field(..., description="User account or project address") diff --git a/backend/v2/project_rewards/services.py b/backend/v2/project_rewards/services.py index b0d4a79155..41eccad474 100644 --- a/backend/v2/project_rewards/services.py +++ b/backend/v2/project_rewards/services.py @@ -2,6 +2,16 @@ from multiproof import StandardMerkleTree from sqlalchemy.ext.asyncio import AsyncSession +from app.constants import ZERO_ADDRESS +from app.engine.octant_rewards import OctantRewardsSettings +from app.engine.user.budget import UserBudgetPayload +from app.engine.user.budget.with_ppf import UserBudgetWithPPF +from app.modules.dto import OctantRewardsDTO +from app.modules.octant_rewards.core import calculate_rewards +from v2.project_rewards.user_events import ( + calculate_effective_deposits, + simulate_user_events, +) from v2.project_rewards.repositories import get_rewards_for_epoch from v2.project_rewards.schemas import ( RewardsMerkleTreeLeafV1, @@ -81,3 +91,78 @@ async def get_rewards_merkle_tree_for_epoch( leaves=leaves, leaf_encoding=LEAF_ENCODING, ) + + +def calculate_octant_rewards( + eth_proceeds: int, + total_effective_deposit: int, +) -> OctantRewardsDTO: + """ + Calculate the octant rewards based on ETH proceeds and total effective deposit. + Proceeds are the what the octant gets from the staking. + Effective deposit is the amount of GLMs that are eligible for rewards. + + Parameters: + eth_proceeds: int - The amount of ETH proceeds. + total_effective_deposit: int - The total amount of GLMs deposited. + + Returns: + OctantRewardsDTO - The octant rewards distribution. + """ + + settings = OctantRewardsSettings() + rewards = calculate_rewards(settings, eth_proceeds, total_effective_deposit) + + # fmt: off + return OctantRewardsDTO( + staking_proceeds = eth_proceeds, + locked_ratio = rewards.locked_ratio, + total_effective_deposit = total_effective_deposit, + total_rewards = rewards.total_rewards, + vanilla_individual_rewards = rewards.vanilla_individual_rewards, + operational_cost = rewards.operational_cost, + ppf = rewards.ppf_value, + community_fund = rewards.community_fund, + ) + # fmt: on + + +def simulate_user_effective_deposits( + epoch_start: int, + epoch_end: int, + epoch_remaining: int, + lock_duration: int, + glm_amount: int, +) -> int: + """ + Simulate the user effective deposits for a given epoch. + """ + + events = { + ZERO_ADDRESS: simulate_user_events( + epoch_end, lock_duration, epoch_remaining, glm_amount + ) + } + _, total_effective_deposit = calculate_effective_deposits( + epoch_start, epoch_end, events + ) + return total_effective_deposit + + +def calculate_user_budget( + user_effective_deposit: int, + rewards: OctantRewardsDTO, +) -> int: + """ + Calculate the user budget based on the user effective deposit and the octant rewards. + """ + + budget_calculator = UserBudgetWithPPF() + return budget_calculator.calculate_budget( + UserBudgetPayload( + user_effective_deposit=user_effective_deposit, + total_effective_deposit=rewards.total_effective_deposit, + vanilla_individual_rewards=rewards.vanilla_individual_rewards, + ppf=rewards.ppf, + ) + ) diff --git a/backend/v2/project_rewards/user_events.py b/backend/v2/project_rewards/user_events.py index c44bbf77f7..4d28d8f000 100644 --- a/backend/v2/project_rewards/user_events.py +++ b/backend/v2/project_rewards/user_events.py @@ -1,36 +1,24 @@ - - -from itertools import groupby -from operator import attrgetter -import os -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from typing import Dict from app.engine.octant_rewards import OctantRewardsSettings from app.engine.user.budget.with_ppf import UserBudgetWithPPF -from app.engine.user.effective_deposit import DepositEvent, EventType, UserDeposit, UserEffectiveDepositPayload -from app.engine.user.effective_deposit.weighted_average.default_with_sablier_timebox import DefaultWeightedAverageWithSablierTimebox -from app.infrastructure.database.models import Deposit -from app.infrastructure.graphql.locks import get_locks_by_timestamp_range -from app.infrastructure.graphql.unlocks import get_unlocks_by_timestamp_range -from app.modules.common.sablier_events_mapper import FlattenStrategy, flatten_sablier_events, process_to_locks_and_unlocks +from app.engine.user.effective_deposit import ( + DepositEvent, + EventType, + UserDeposit, + UserEffectiveDepositPayload, +) from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO from app.modules.octant_rewards.core import calculate_rewards from app.modules.snapshots.pending.core import calculate_user_budgets -from app.modules.user.budgets.core import get_upcoming_budget -from app.modules.user.events_generator.core import unify_deposit_balances from app.modules.staking.proceeds.core import estimate_staking_proceeds -from app.constants import SABLIER_SENDER_ADDRESS_SEPOLIA, SABLIER_TOKEN_ADDRESS_SEPOLIA, ZERO_ADDRESS -from app.infrastructure import SubgraphEndpoints +from app.constants import ( + ZERO_ADDRESS, +) +from app.engine.user.effective_deposit.weighted_average.default import ( + DefaultWeightedAverageEffectiveDeposit, +) from v2.deposits.repositories import DepositEventsRepository -from v2.epochs.subgraphs import EpochsSubgraph -from v2.sablier.subgraphs import SablierSubgraph - - - - def calculate_effective_deposits( @@ -38,9 +26,8 @@ def calculate_effective_deposits( end_sec: int, events: Dict[str, list[DepositEvent]], ) -> tuple[list[UserDeposit], int]: - # TODO: We can do this better and nicer - effective_deposit_calculator = DefaultWeightedAverageWithSablierTimebox() + effective_deposit_calculator = DefaultWeightedAverageEffectiveDeposit() payload = UserEffectiveDepositPayload( epoch_start=start_sec, epoch_end=end_sec, @@ -56,20 +43,19 @@ async def calculate_pending_epoch_snapshot( epoch_start: int, epoch_end: int, ) -> PendingSnapshotDTO: - # Get octant rewards - # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) + # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) # duration_sec = epoch_details.duration # return estimate_staking_proceeds(duration_sec) # eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) eth_proceeds = estimate_staking_proceeds(epoch_end - epoch_start) events = await deposit_events.get_all_users_events( - epoch_number, - epoch_start, - epoch_end + epoch_number, epoch_start, epoch_end + ) + user_deposits, total_effective_deposit = calculate_effective_deposits( + epoch_start, epoch_end, events ) - user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) # total_effective_deposit = 155654569757136462439580980 rewards_settings = OctantRewardsSettings() @@ -109,27 +95,9 @@ async def calculate_pending_epoch_snapshot( ) -async def get_budget( - session: AsyncSession, - epoch_number: int, - user_address: str -) -> int: - simulated_snapshot = await calculate_pending_epoch_snapshot(session, epoch_number) - - upcoming_budget = get_upcoming_budget( - user_address, simulated_snapshot.user_budgets - ) - - return upcoming_budget - - def simulate_user_events( - end_sec: int, - lock_duration: int, - remaining_sec: int, - glm_amount: int + end_sec: int, lock_duration: int, remaining_sec: int, glm_amount: int ) -> list[DepositEvent]: - user_events = [ DepositEvent( user=ZERO_ADDRESS, @@ -149,4 +117,4 @@ def simulate_user_events( deposit_before=glm_amount, ) ) - return user_events \ No newline at end of file + return user_events diff --git a/backend/v2/projects/contracts.py b/backend/v2/projects/contracts.py index 8656b1a7b1..4f5b733332 100644 --- a/backend/v2/projects/contracts.py +++ b/backend/v2/projects/contracts.py @@ -1,14 +1,19 @@ import logging +from v2.core.types import Address +from v2.core.transformers import transform_to_checksum_address from v2.core.contracts import SmartContract class ProjectsContracts(SmartContract): - async def get_project_addresses(self, epoch_number: int) -> list[str]: + async def get_project_addresses(self, epoch_number: int) -> list[Address]: logging.debug( f"[Projects contract] Getting project addresses for epoch: {epoch_number}" ) - return await self.contract.functions.getProposalAddresses(epoch_number).call() + addresses = await self.contract.functions.getProposalAddresses( + epoch_number + ).call() + return [transform_to_checksum_address(address) for address in addresses] async def get_project_cid(self) -> str: logging.debug("[Projects contract] Getting projects CID") diff --git a/backend/v2/projects/dependencies.py b/backend/v2/projects/dependencies.py index 2f6dc5264f..7dbe08868c 100644 --- a/backend/v2/projects/dependencies.py +++ b/backend/v2/projects/dependencies.py @@ -71,13 +71,16 @@ def get_projects_allocation_threshold_getter( epoch_number: GetOpenAllocationWindowEpochNumber, session: GetSession, projects: GetProjectsContracts, + settings: Annotated[ + ProjectsAllocationThresholdSettings, + Depends(get_projects_allocation_threshold_settings), + ], ) -> ProjectsAllocationThresholdGetter: - project_count_multiplier = 2 if epoch_number <= 2 else 1 - return ProjectsAllocationThresholdGetter( - epoch_number, session, projects, project_count_multiplier + epoch_number, session, projects, settings.project_count_multiplier ) + async def get_projects_metadata_getter( epoch_number: EpochNumberPath, projects_contracts: GetProjectsContracts, @@ -94,9 +97,11 @@ async def get_projects_metadata_getter( async def get_projects_details_getter( - session: GetSession, epochs: EpochsParameter, search_phrases: SearchPhrasesParameter + session: GetSession, + epochs: EpochsParameter, + search_phrases_param: SearchPhrasesParameter, ) -> ProjectsDetailsGetter: - epoch_numbers, search_phrases = process_search_params(epochs, search_phrases) + epoch_numbers, search_phrases = process_search_params(epochs, search_phrases_param) return ProjectsDetailsGetter( session=session, epoch_numbers=epoch_numbers, search_phrases=search_phrases ) diff --git a/backend/v2/sablier/dependencies.py b/backend/v2/sablier/dependencies.py index 0f9ffb4fec..b58600f539 100644 --- a/backend/v2/sablier/dependencies.py +++ b/backend/v2/sablier/dependencies.py @@ -3,14 +3,9 @@ from fastapi import Depends from pydantic import Field -from pydantic_settings import SettingsConfigDict from app.shared.blockchain_types import ChainTypes from v2.sablier.subgraphs import SablierSubgraph -from v2.core.dependencies import GetChainSettings, GetSession, OctantSettings -from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator -from v2.project_rewards.services import ProjectRewardsEstimator -from v2.projects.dependencies import GetProjectsContracts +from v2.core.dependencies import GetChainSettings, OctantSettings class SablierSubgraphSettings(OctantSettings): @@ -48,6 +43,7 @@ def get_sablier_subgraph(chain_settings: GetChainSettings) -> SablierSubgraph: sablier_settings.sablier_token_address_sepolia, ) + GetSablierSubgraph = Annotated[ SablierSubgraph, Depends(get_sablier_subgraph), diff --git a/backend/v2/sablier/subgraphs.py b/backend/v2/sablier/subgraphs.py index fa2e0357fe..c2b0785044 100644 --- a/backend/v2/sablier/subgraphs.py +++ b/backend/v2/sablier/subgraphs.py @@ -10,11 +10,10 @@ from app.infrastructure.sablier.events import SablierStream from v2.epochs.subgraphs import BackoffParams -from gql.transport.aiohttp import log as requests_logger requests_logger.setLevel(logging.WARNING) -class SablierSubgraph: +class SablierSubgraph: def __init__( self, url: str, @@ -22,15 +21,14 @@ def __init__( token_address: str, backoff_params: BackoffParams | None = None, ): - - requests_logger.setLevel(logging.WARNING) + requests_logger.setLevel(logging.WARNING) self.url = url self.sender = sender self.token_address = token_address self.gql_client = Client( transport=AIOHTTPTransport( - url=self.url, + url=self.url, timeout=2, ), fetch_schema_from_transport=False, @@ -48,9 +46,7 @@ def __init__( self.gql_client.execute_async ) - async def _fetch_streams(self, query: str, variables: dict) -> list[SablierStream]: - all_streams = [] has_more = True limit = 1000 From 14ad8ce66b85d261b3cde1a80defc3d4523c7a9c Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 00:23:23 +0100 Subject: [PATCH 04/14] Adds tests, and addresses some PR comments --- backend/tests/v2/factories/__init__.py | 9 + backend/tests/v2/factories/budgets.py | 54 +++++ .../tests/v2/factories/finalized_snapshots.py | 66 ++++++ .../tests/v2/factories/pending_snapshots.py | 81 +++++++ backend/tests/v2/fake_subgraphs/epochs.py | 15 ++ backend/tests/v2/project_rewards/__init__.py | 0 .../project_rewards/test_estimated_budged.py | 207 ++++++++++++++++++ .../test_get_budget_for_epoch.py | 66 ++++++ .../project_rewards/test_get_epoch_budgets.py | 114 ++++++++++ .../test_get_rewards_leverage.py | 191 ++++++++++++++++ .../test_get_upcoming_budget.py | 109 +++++++++ backend/v2/allocations/dependencies.py | 4 +- backend/v2/allocations/services.py | 2 +- backend/v2/allocations/socket.py | 6 +- backend/v2/core/dependencies.py | 9 +- backend/v2/matched_rewards/dependencies.py | 28 ++- ...pped_quadriatic.py => capped_quadratic.py} | 22 +- backend/v2/project_rewards/dependencies.py | 4 +- backend/v2/project_rewards/router.py | 26 ++- backend/v2/project_rewards/schemas.py | 4 +- backend/v2/project_rewards/services.py | 10 +- 21 files changed, 985 insertions(+), 42 deletions(-) create mode 100644 backend/tests/v2/factories/budgets.py create mode 100644 backend/tests/v2/factories/finalized_snapshots.py create mode 100644 backend/tests/v2/factories/pending_snapshots.py create mode 100644 backend/tests/v2/project_rewards/__init__.py create mode 100644 backend/tests/v2/project_rewards/test_estimated_budged.py create mode 100644 backend/tests/v2/project_rewards/test_get_budget_for_epoch.py create mode 100644 backend/tests/v2/project_rewards/test_get_epoch_budgets.py create mode 100644 backend/tests/v2/project_rewards/test_get_rewards_leverage.py create mode 100644 backend/tests/v2/project_rewards/test_get_upcoming_budget.py rename backend/v2/project_rewards/{capped_quadriatic.py => capped_quadratic.py} (91%) diff --git a/backend/tests/v2/factories/__init__.py b/backend/tests/v2/factories/__init__.py index 561003f34f..4e5a0647ff 100644 --- a/backend/tests/v2/factories/__init__.py +++ b/backend/tests/v2/factories/__init__.py @@ -4,6 +4,9 @@ from tests.v2.factories.allocations import AllocationFactorySet from tests.v2.factories.projects_details import ProjectsDetailsFactorySet from tests.v2.factories.users import UserFactorySet +from tests.v2.factories.budgets import BudgetFactorySet +from tests.v2.factories.pending_snapshots import PendingEpochSnapshotFactorySet +from tests.v2.factories.finalized_snapshots import FinalizedEpochSnapshotFactorySet from dataclasses import dataclass @@ -23,6 +26,9 @@ class FactoriesAggregator: allocation_requests: AllocationRequestFactorySet allocations: AllocationFactorySet projects_details: ProjectsDetailsFactorySet + budgets: BudgetFactorySet + pending_snapshots: PendingEpochSnapshotFactorySet + finalized_snapshots: FinalizedEpochSnapshotFactorySet def __init__(self, fast_session: AsyncSession): """ @@ -32,3 +38,6 @@ def __init__(self, fast_session: AsyncSession): self.allocation_requests = AllocationRequestFactorySet(fast_session) self.allocations = AllocationFactorySet(fast_session) self.projects_details = ProjectsDetailsFactorySet(fast_session) + self.budgets = BudgetFactorySet(fast_session) + self.pending_snapshots = PendingEpochSnapshotFactorySet(fast_session) + self.finalized_snapshots = FinalizedEpochSnapshotFactorySet(fast_session) diff --git a/backend/tests/v2/factories/budgets.py b/backend/tests/v2/factories/budgets.py new file mode 100644 index 0000000000..a14a0877fe --- /dev/null +++ b/backend/tests/v2/factories/budgets.py @@ -0,0 +1,54 @@ +import random +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory +from factory import LazyAttribute +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.database.models import Budget, User +from tests.v2.factories.base import FactorySetBase +from tests.v2.factories.users import UserFactorySet +from v2.core.types import Address, BigInteger + + +class BudgetFactory(AsyncSQLAlchemyFactory): + class Meta: + model = Budget + sqlalchemy_session_persistence = "commit" + + user_id = None + epoch = None + budget = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) # Random amount in wei + + +class BudgetFactorySet(FactorySetBase): + _factories = {"budget": BudgetFactory} + + async def create( + self, + user: User | Address, + epoch: int, + amount: BigInteger | None = None, + ) -> Budget: + """ + Create a budget for a user in a specific epoch. + + Args: + user: The user or user address to create the budget for + epoch: The epoch number + amount: Optional specific amount, otherwise random + + Returns: + The created budget + """ + if not isinstance(user, User): + user = await UserFactorySet(self.session).get_or_create(user) + + factory_kwargs = { + "user_id": user.id, + "epoch": epoch, + } + + if amount is not None: + factory_kwargs["budget"] = str(amount) + + budget = await BudgetFactory.create(**factory_kwargs) + return budget \ No newline at end of file diff --git a/backend/tests/v2/factories/finalized_snapshots.py b/backend/tests/v2/factories/finalized_snapshots.py new file mode 100644 index 0000000000..c66d967687 --- /dev/null +++ b/backend/tests/v2/factories/finalized_snapshots.py @@ -0,0 +1,66 @@ +import random +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory +from factory import LazyAttribute +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.database.models import FinalizedEpochSnapshot +from tests.v2.factories.base import FactorySetBase +from v2.core.types import BigInteger + + +class FinalizedEpochSnapshotFactory(AsyncSQLAlchemyFactory): + class Meta: + model = FinalizedEpochSnapshot + sqlalchemy_session_persistence = "commit" + + epoch = None + matched_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + patrons_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + leftover = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) + withdrawals_merkle_root = LazyAttribute(lambda _: "0x" + "".join(random.choices("0123456789abcdef", k=64))) + total_withdrawals = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + + +class FinalizedEpochSnapshotFactorySet(FactorySetBase): + _factories = {"finalized_snapshot": FinalizedEpochSnapshotFactory} + + async def create( + self, + epoch: int, + matched_rewards: BigInteger | None = None, + patrons_rewards: BigInteger | None = None, + leftover: BigInteger | None = None, + withdrawals_merkle_root: str | None = None, + total_withdrawals: BigInteger | None = None, + ) -> FinalizedEpochSnapshot: + """ + Create a finalized epoch snapshot. + + Args: + epoch: The epoch number + matched_rewards: Optional matched rewards amount + patrons_rewards: Optional patrons rewards amount + leftover: Optional leftover amount + withdrawals_merkle_root: Optional withdrawals merkle root + total_withdrawals: Optional total withdrawals amount + + Returns: + The created finalized epoch snapshot + """ + factory_kwargs = { + "epoch": epoch, + } + + if matched_rewards is not None: + factory_kwargs["matched_rewards"] = str(matched_rewards) + if patrons_rewards is not None: + factory_kwargs["patrons_rewards"] = str(patrons_rewards) + if leftover is not None: + factory_kwargs["leftover"] = str(leftover) + if withdrawals_merkle_root is not None: + factory_kwargs["withdrawals_merkle_root"] = withdrawals_merkle_root + if total_withdrawals is not None: + factory_kwargs["total_withdrawals"] = str(total_withdrawals) + + snapshot = await FinalizedEpochSnapshotFactory.create(**factory_kwargs) + return snapshot \ No newline at end of file diff --git a/backend/tests/v2/factories/pending_snapshots.py b/backend/tests/v2/factories/pending_snapshots.py new file mode 100644 index 0000000000..9abaa33e71 --- /dev/null +++ b/backend/tests/v2/factories/pending_snapshots.py @@ -0,0 +1,81 @@ +import random +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory +from factory import LazyAttribute +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.database.models import PendingEpochSnapshot +from tests.v2.factories.base import FactorySetBase +from v2.core.types import BigInteger + + +class PendingEpochSnapshotFactory(AsyncSQLAlchemyFactory): + class Meta: + model = PendingEpochSnapshot + sqlalchemy_session_persistence = "commit" + + epoch = None + eth_proceeds = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + total_effective_deposit = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + locked_ratio = LazyAttribute(lambda _: str(random.randint(1, 100))) + total_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + vanilla_individual_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + operational_cost = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) + ppf = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) + community_fund = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) + + +class PendingEpochSnapshotFactorySet(FactorySetBase): + _factories = {"pending_snapshot": PendingEpochSnapshotFactory} + + async def create( + self, + epoch: int, + eth_proceeds: BigInteger | None = None, + total_effective_deposit: BigInteger | None = None, + locked_ratio: BigInteger | None = None, + total_rewards: BigInteger | None = None, + vanilla_individual_rewards: BigInteger | None = None, + operational_cost: BigInteger | None = None, + ppf: BigInteger | None = None, + community_fund: BigInteger | None = None, + ) -> PendingEpochSnapshot: + """ + Create a pending epoch snapshot. + + Args: + epoch: The epoch number + eth_proceeds: Optional ETH proceeds amount + total_effective_deposit: Optional total effective deposit amount + locked_ratio: Optional locked ratio + total_rewards: Optional total rewards amount + vanilla_individual_rewards: Optional vanilla individual rewards amount + operational_cost: Optional operational cost amount + ppf: Optional PPF amount + community_fund: Optional community fund amount + + Returns: + The created pending epoch snapshot + """ + factory_kwargs = { + "epoch": epoch, + } + + if eth_proceeds is not None: + factory_kwargs["eth_proceeds"] = str(eth_proceeds) + if total_effective_deposit is not None: + factory_kwargs["total_effective_deposit"] = str(total_effective_deposit) + if locked_ratio is not None: + factory_kwargs["locked_ratio"] = str(locked_ratio) + if total_rewards is not None: + factory_kwargs["total_rewards"] = str(total_rewards) + if vanilla_individual_rewards is not None: + factory_kwargs["vanilla_individual_rewards"] = str(vanilla_individual_rewards) + if operational_cost is not None: + factory_kwargs["operational_cost"] = str(operational_cost) + if ppf is not None: + factory_kwargs["ppf"] = str(ppf) + if community_fund is not None: + factory_kwargs["community_fund"] = str(community_fund) + + snapshot = await PendingEpochSnapshotFactory.create(**factory_kwargs) + return snapshot \ No newline at end of file diff --git a/backend/tests/v2/fake_subgraphs/epochs.py b/backend/tests/v2/fake_subgraphs/epochs.py index 758a50943d..71c47c6239 100644 --- a/backend/tests/v2/fake_subgraphs/epochs.py +++ b/backend/tests/v2/fake_subgraphs/epochs.py @@ -1,5 +1,7 @@ +from pydantic import TypeAdapter from app import exceptions from app.context.epoch.details import EpochDetails +from v2.epochs.subgraphs import EpochSubgraphItem from tests.v2.fake_subgraphs.helpers import FakeEpochEventDetails from v2.core.exceptions import EpochsNotFound @@ -13,6 +15,19 @@ def __init__(self, epochs_events: list[FakeEpochEventDetails] | None = None): lambda epoch_event: epoch_event.to_dict(), epochs_events ) + async def fetch_epoch_by_number(self, epoch_number: int) -> EpochSubgraphItem: + """ + Simulate fetching epoch details by epoch number. + """ + matching_epochs = [ + epoch for epoch in self.epochs_events if epoch["epoch"] == epoch_number + ] + + if not matching_epochs: + raise exceptions.EpochNotIndexed(epoch_number) + + return TypeAdapter(EpochSubgraphItem).validate_python(matching_epochs[0]) + async def get_epoch_by_number(self, epoch_number: int) -> EpochDetails: """ Simulate fetching epoch details by epoch number. diff --git a/backend/tests/v2/project_rewards/__init__.py b/backend/tests/v2/project_rewards/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/v2/project_rewards/test_estimated_budged.py b/backend/tests/v2/project_rewards/test_estimated_budged.py new file mode 100644 index 0000000000..9a38b870a0 --- /dev/null +++ b/backend/tests/v2/project_rewards/test_estimated_budged.py @@ -0,0 +1,207 @@ +from unittest.mock import MagicMock +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from v2.matched_rewards.services import MatchedRewardsEstimator +from v2.glms.dependencies import get_glm_balance_of_deposits +from tests.v2.factories import FactoriesAggregator +from tests.v2.fake_contracts.helpers import FakeEpochsContractDetails +from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory, FakeEpochsContractCallable +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimatorInAW, get_matched_rewards_estimator_only_in_aw +from fastapi import FastAPI + + +@pytest.mark.asyncio +async def test_estimates_budget_for_single_epoch( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_app: FastAPI, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should correctly estimate budget for a single epoch deposit + We are testing this while in finalized epoch. + """ + # Given + glm_amount = 1000 * 10**18 # 1000 GLM + epoch_duration = 90 * 24 * 60 * 60 # 90 days in seconds + epoch_start = 1000 + finalized_epoch = 1 + glm_balance = 5000 * 10**18 # 5000 GLM total in contract + + # Mock contracts + fake_epochs_contract_factory( + FakeEpochsContractDetails( + finalized_epoch=finalized_epoch, + current_epoch=finalized_epoch + 1, + pending_epoch=None, + future_epoch_props=(0, 0, epoch_start, epoch_duration) + ) + ) + + # Create pending epoch snapshot + pending_snapshot = await factories.pending_snapshots.create( + epoch=finalized_epoch + ) + + # finalize epoch snapshot + finalized_snapshot = await factories.finalized_snapshots.create( + epoch=finalized_epoch, + ) + + # Mock GLM balance + fast_app.dependency_overrides[get_glm_balance_of_deposits] = lambda: glm_balance + + # Mock matched rewards estimator + # rewards_estimator_mock = MagicMock(spec=MatchedRewardsEstimator) + # rewards_estimator_mock.get.return_value = leverage + # fast_app.dependency_overrides[get_matched_rewards_estimator] = lambda: rewards_estimator_mock + + request_data = { + "glm_amount": str(glm_amount), + "number_of_epochs": 1 + } + + async with fast_client as client: + resp = await client.post("/rewards/estimated_budget", json=request_data) + + print("resp", resp.json()) + assert resp.status_code == 200 + response = resp.json() + assert response["budget"] != "0" + assert response["matchedFunding"] == "0" + + +@pytest.mark.asyncio +async def test_estimates_budget_for_multiple_epochs( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_app: FastAPI, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should correctly estimate budget for multiple epochs""" + # Given + glm_amount = 1000 * 10**18 + number_of_epochs = 1 + epoch_duration = 90 * 24 * 60 * 60 + epoch_start = 1000 + finalized_epoch = 1 + glm_balance = 5000 * 10**18 + + alice = await factories.users.get_or_create_alice() + bob = await factories.users.get_or_create_bob() + alice_allocation = await factories.allocations.create( + user=alice, + epoch=finalized_epoch, + ) + bob_allocation = await factories.allocations.create( + user=bob, + epoch=finalized_epoch, + ) + + # Create pending epoch snapshot + pending_snapshot = await factories.pending_snapshots.create( + epoch=finalized_epoch + ) + + # finalize epoch snapshot + finalized_snapshot = await factories.finalized_snapshots.create( + epoch=finalized_epoch, + ) + + # Mock contracts + fake_epochs_contract_factory( + FakeEpochsContractDetails( + finalized_epoch=finalized_epoch, + future_epoch_props=(0, 0, epoch_start, epoch_duration) + ) + ) + + # Mock dependencies + fast_app.dependency_overrides[get_glm_balance_of_deposits] = lambda: glm_balance + + # Mock matched rewards estimator + # rewards_estimator_mock = MagicMock(spec=GetMatchedRewardsEstimatorInAW) + # rewards_estimator_mock.get.return_value = leverage + # fast_app.dependency_overrides[GetMatchedRewardsEstimatorInAW] = lambda: rewards_estimator_mock + + request_data = { + "glm_amount": str(glm_amount), + "number_of_epochs": number_of_epochs + } + + async with fast_client as client: + resp = await client.post("/rewards/estimated_budget", json=request_data) + print("result", resp.json()) + + assert resp.status_code == 200 + result = resp.json() + + assert result["budget"] != "0" + assert result["matchedFunding"] != "0" + leverage = int(finalized_snapshot.matched_rewards) / (int(alice_allocation.amount) + int(bob_allocation.amount)) + assert int(result["matchedFunding"]) == int(int(result["budget"]) * leverage) + + one_epoch_budget = int(result["budget"]) + one_epoch_matched_funding = int(result["matchedFunding"]) + + # Test two epochs + request_data = { + "glm_amount": str(glm_amount), + "number_of_epochs": 2 + } + + resp = await client.post("/rewards/estimated_budget", json=request_data) + + assert resp.status_code == 200 + result = resp.json() + print("result", result) + assert int(result["budget"]) != "0" + assert int(result["matchedFunding"]) != "0" + + assert int(result["budget"]) == one_epoch_budget * 2 + assert int(result["matchedFunding"]) == one_epoch_matched_funding * 2 + + + # Test zero GLM amount + request_data = { + "glm_amount": str(0), + "number_of_epochs": 1 + } + + resp = await client.post("/rewards/estimated_budget", json=request_data) + + assert resp.status_code == 200 + result = resp.json() + assert int(result["budget"]) == 0 + assert int(result["matchedFunding"]) == 0 + + +@pytest.mark.asyncio +async def test_validates_request_parameters( + fast_client: AsyncClient, +): + """Should validate request parameters""" + async with fast_client as client: + # Test negative GLM amount + resp = await client.post("/rewards/estimated_budget", json={ + "glm_amount": "-1000", + "number_of_epochs": 1 + }) + assert resp.status_code == 422 + + # Test zero epochs + resp = await client.post("/rewards/estimated_budget", json={ + "glm_amount": "1000", + "number_of_epochs": 0 + }) + assert resp.status_code == 422 + + # Test negative epochs + resp = await client.post("/rewards/estimated_budget", json={ + "glm_amount": "1000", + "number_of_epochs": -1 + }) + assert resp.status_code == 422 \ No newline at end of file diff --git a/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py b/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py new file mode 100644 index 0000000000..776c5a26f8 --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py @@ -0,0 +1,66 @@ +import pytest +from fastapi import status +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.v2.factories import FactoriesAggregator + +"""Test cases for the GET /rewards/budget/{user_address}/epoch/{epoch_number} endpoint""" + + +@pytest.mark.asyncio +async def test_returns_none_when_no_budget( + fast_client: AsyncClient, fast_session: AsyncSession, factories: FactoriesAggregator +): + """Should return 204 No Content when user has no budget for the epoch""" + # Given: a user with no budget + alice = await factories.users.get_or_create_alice() + + async with fast_client as client: + resp = await client.get(f"rewards/budget/{alice.address}/epoch/1") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert resp.text == "" + + +@pytest.mark.asyncio +async def test_returns_budget_when_it_exists( + fast_client: AsyncClient, factories: FactoriesAggregator +): + """Should return user's budget when it exists for the epoch""" + + # Given: users with budgets for different epochs + alice = await factories.users.get_or_create_alice() + budget1 = await factories.budgets.create(user=alice, epoch=1) + budget2 = await factories.budgets.create(user=alice, epoch=2) + + async with fast_client as client: + # Budget for epoch 1 + resp = await client.get(f"rewards/budget/{alice.address}/epoch/1") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"budget": budget1.budget} + + # Budget for epoch 2 + resp = await client.get(f"rewards/budget/{alice.address}/epoch/2") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"budget": budget2.budget} + + # No budget for epoch 3 + resp = await client.get(f"rewards/budget/{alice.address}/epoch/3") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert resp.text == "" + + +@pytest.mark.asyncio +async def test_returns_zero_budget_when_amount_is_zero( + fast_client: AsyncClient, factories: FactoriesAggregator +): + """Should return budget even when amount is zero""" + + # Given: a user with zero budget + alice = await factories.users.get_or_create_alice() + budget = await factories.budgets.create(user=alice, epoch=1, amount=0) + + async with fast_client as client: + resp = await client.get(f"rewards/budget/{alice.address}/epoch/1") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"budget": "0"} diff --git a/backend/tests/v2/project_rewards/test_get_epoch_budgets.py b/backend/tests/v2/project_rewards/test_get_epoch_budgets.py new file mode 100644 index 0000000000..46f7ce52c0 --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_epoch_budgets.py @@ -0,0 +1,114 @@ +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from tests.v2.factories import FactoriesAggregator + + +@pytest.mark.asyncio +async def test_returns_empty_budgets_when_no_users( + fast_client: AsyncClient, + fast_session: AsyncSession, +): + """Should return empty budgets list when no users have budgets for the epoch""" + + async with fast_client as client: + resp = await client.get("/rewards/budgets/epoch/1") + assert resp.status_code == 200 + assert resp.json() == {"budgets": []} + + +@pytest.mark.asyncio +async def test_returns_all_user_budgets_for_epoch( + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return budgets for all users in the given epoch""" + + # Given: multiple users with budgets in different epochs + alice = await factories.users.get_or_create_alice() + bob = await factories.users.get_or_create_bob() + charlie = await factories.users.get_or_create_charlie() + + # Create budgets for different epochs + alice_budget1 = await factories.budgets.create(user=alice, epoch=1, amount=100) + bob_budget1 = await factories.budgets.create(user=bob, epoch=1, amount=200) + charlie_budget1 = await factories.budgets.create(user=charlie, epoch=1, amount=300) + + # Create budgets for a different epoch + await factories.budgets.create(user=alice, epoch=2, amount=400) + await factories.budgets.create(user=bob, epoch=2, amount=500) + + async with fast_client as client: + # When: requesting budgets for epoch 1 + resp = await client.get("/rewards/budgets/epoch/1") + + # Then: should return all budgets for epoch 1 + assert resp.status_code == 200 + budgets = resp.json()["budgets"] + + # Convert to set for order-independent comparison + expected_budgets = [ + {"address": alice.address, "amount": str(alice_budget1.budget)}, + {"address": bob.address, "amount": str(bob_budget1.budget)}, + {"address": charlie.address, "amount": str(charlie_budget1.budget)}, + ] + assert len(budgets) == 3 + assert {(b["address"], b["amount"]) for b in budgets} == { + (b["address"], b["amount"]) for b in expected_budgets + } + + +@pytest.mark.asyncio +async def test_returns_empty_budgets_for_future_epoch( + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return empty budgets list for an epoch with no budgets""" + + # Given: users with budgets in epoch 1 + alice = await factories.users.get_or_create_alice() + bob = await factories.users.get_or_create_bob() + + await factories.budgets.create(user=alice, epoch=1, amount=100) + await factories.budgets.create(user=bob, epoch=1, amount=200) + + async with fast_client as client: + # When: requesting budgets for epoch 2 (which has no budgets) + resp = await client.get("/rewards/budgets/epoch/2") + + # Then: should return empty list + assert resp.status_code == 200 + assert resp.json() == {"budgets": []} + + +@pytest.mark.asyncio +async def test_includes_zero_budgets( + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should include users with zero budgets in the response""" + + # Given: users with zero and non-zero budgets + alice = await factories.users.get_or_create_alice() + bob = await factories.users.get_or_create_bob() + + alice_budget = await factories.budgets.create(user=alice, epoch=1, amount=100) + bob_budget = await factories.budgets.create(user=bob, epoch=1, amount=0) + + async with fast_client as client: + resp = await client.get("/rewards/budgets/epoch/1") + + assert resp.status_code == 200 + budgets = resp.json()["budgets"] + + expected_budgets = [ + {"address": alice.address, "amount": str(alice_budget.budget)}, + {"address": bob.address, "amount": "0"}, + ] + assert len(budgets) == 2 + assert {(b["address"], b["amount"]) for b in budgets} == { + (b["address"], b["amount"]) for b in expected_budgets + } \ No newline at end of file diff --git a/backend/tests/v2/project_rewards/test_get_rewards_leverage.py b/backend/tests/v2/project_rewards/test_get_rewards_leverage.py new file mode 100644 index 0000000000..3fe0d40093 --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_rewards_leverage.py @@ -0,0 +1,191 @@ +from fastapi import FastAPI +import pytest +from http import HTTPStatus +from unittest.mock import MagicMock +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.epoch_state import EpochState +from app.exceptions import InvalidEpoch, MissingSnapshot, NotImplementedForGivenEpochState +from v2.matched_rewards.services import MatchedRewardsEstimator +from tests.v2.factories import FactoriesAggregator +from tests.v2.fake_contracts.helpers import FakeEpochsContractDetails +from tests.v2.fake_contracts.conftest import FakeEpochsContractCallable +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator, get_matched_rewards_estimator +from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory, FakeEpochsContractCallable + + +@pytest.mark.asyncio +async def test_returns_leverage_for_finalized_epoch( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return leverage based on finalized snapshot for finalized epoch""" + # Given: a finalized epoch with snapshot and allocations + epoch_number = 1 + + # Mock contracts to return finalized state + fake_epochs_contract_factory( + FakeEpochsContractDetails( + current_epoch=epoch_number + 1, # Current epoch is ahead + finalized_epoch=epoch_number, + pending_epoch=None, + ) + ) + + # Create pending snapshot + pending_snapshot = await factories.pending_snapshots.create( + epoch=epoch_number, + ) + + # Create finalized snapshot + finalized_snapshot = await factories.finalized_snapshots.create( + epoch=epoch_number, + ) + + # Create some allocations + alice = await factories.users.get_or_create_alice() + alice_allocation = await factories.allocations.create( + user=alice, + epoch=epoch_number, + ) + + async with fast_client as client: + resp = await client.get(f"rewards/leverage/{epoch_number}") + assert resp.status_code == HTTPStatus.OK + resp_leverage = int(resp.json()["leverage"]) + expected_leverage = int(int(finalized_snapshot.matched_rewards) / int(alice_allocation.amount)) + assert resp_leverage == expected_leverage + + +@pytest.mark.asyncio +async def test_returns_leverage_for_pending_epoch( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, + fast_app: FastAPI, +): + """Should return leverage based on estimated rewards for pending epoch""" + # Given: a pending epoch with allocations + epoch_number = 1 + estimated_rewards = 100_000_000 + + # Mock contracts to return pending state + fake_epochs_contract_factory( + FakeEpochsContractDetails( + current_epoch=epoch_number + 1, # Current epoch is ahead + finalized_epoch=epoch_number, + pending_epoch=None, + ) + ) + + # Create pending snapshot + pending_snapshot = await factories.pending_snapshots.create( + epoch=epoch_number, + ) + + # Create some allocations + alice = await factories.users.get_or_create_alice() + alice_allocation = await factories.allocations.create( + user=alice, + epoch=epoch_number, + ) + + # Mock rewards estimator + rewards_estimator = MagicMock(spec=MatchedRewardsEstimator) + rewards_estimator.get.return_value = estimated_rewards + fast_app.dependency_overrides[get_matched_rewards_estimator] = lambda: rewards_estimator + + async with fast_client as client: + resp = await client.get(f"rewards/leverage/{epoch_number}") + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"leverage": estimated_rewards / alice_allocation.amount} + + +@pytest.mark.asyncio +async def test_returns_zero_leverage_when_no_allocations( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return zero leverage when there are no allocations""" + # Given: a finalized epoch with snapshot but no allocations + epoch_number = 1 + matched_rewards = 1000 * 10**18 + + # Mock contracts to return finalized state + fake_epochs_contract_factory( + FakeEpochsContractDetails( + current_epoch=2, + finalized_epoch=epoch_number, + ) + ) + + # Create pending snapshot + pending_snapshot = await factories.pending_snapshots.create( + epoch=epoch_number, + ) + + # Create finalized snapshot + finalized_snapshot = await factories.finalized_snapshots.create( + epoch=epoch_number, + matched_rewards=matched_rewards, + ) + + async with fast_client as client: + resp = await client.get(f"rewards/leverage/{epoch_number}") + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"leverage": 0} + + +@pytest.mark.asyncio +async def test_raises_error_when_snapshot_missing( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, +): + """Should raise MissingSnapshot when finalized snapshot is missing""" + # Given: a finalized epoch without snapshot + epoch_number = 1 + + # Mock contracts to return finalized state + fake_epochs_contract_factory( + FakeEpochsContractDetails( + current_epoch=2, + finalized_epoch=epoch_number, + ) + ) + + async with fast_client as client: + resp = await client.get(f"rewards/leverage/{epoch_number}") + assert resp.status_code == HTTPStatus.BAD_REQUEST + assert resp.json() == {"message": InvalidEpoch.description} + + +@pytest.mark.asyncio +async def test_raises_error_for_invalid_epoch_state( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, +): + """Should raise NotImplementedForGivenEpochState for invalid epoch state""" + # Given: an epoch in invalid state (after PENDING) + epoch_number = 1 + + # Mock contracts to return future epoch + fake_epochs_contract_factory( + FakeEpochsContractDetails( + current_epoch=1, + finalized_epoch=0, + future_epoch_props=(0, 0, 2000, 1000), # Future epoch + ) + ) + + async with fast_client as client: + resp = await client.get(f"rewards/leverage/{epoch_number}") + assert resp.status_code == HTTPStatus.BAD_REQUEST + assert resp.json() == {"message": NotImplementedForGivenEpochState.description} \ No newline at end of file diff --git a/backend/tests/v2/project_rewards/test_get_upcoming_budget.py b/backend/tests/v2/project_rewards/test_get_upcoming_budget.py new file mode 100644 index 0000000000..cfad796046 --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_upcoming_budget.py @@ -0,0 +1,109 @@ +from unittest.mock import MagicMock +import pytest +from http import HTTPStatus +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.engine.user.effective_deposit import DepositEvent, DepositSource, EventType +from v2.deposits.dependencies import GetDepositEventsRepository, get_deposit_events_repository +from tests.v2.factories import FactoriesAggregator +from tests.v2.fake_contracts.helpers import FakeEpochsContractDetails +from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory, FakeEpochsContractCallable +from tests.v2.fake_subgraphs.conftest import fake_epochs_subgraph_factory, FakeEpochsSubgraphCallable +from tests.v2.fake_subgraphs.helpers import FakeEpochEventDetails +from fastapi import FastAPI +from v2.core.dependencies import get_current_timestamp + + +@pytest.mark.asyncio +async def test_returns_upcoming_budget_with_no_deposits( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fake_epochs_subgraph_factory: FakeEpochsSubgraphCallable, + fast_app: FastAPI, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return zero budget when user has no deposits""" + # Given: a user and mocked epoch data + alice = await factories.users.get_or_create_alice() + current_epoch = 1 + current_timestamp = 1000 + epoch_start = 0 + + # Mock contracts and subgraph + fake_epochs_contract_factory( + FakeEpochsContractDetails(current_epoch=current_epoch) + ) + + # Mock subgraph response + fake_epochs_subgraph_factory([ + FakeEpochEventDetails( + epoch=current_epoch, + from_ts=epoch_start, + to_ts=2000, + ) + ]) + + events_repository = MagicMock(spec=GetDepositEventsRepository) + events_repository.get_all_users_events.return_value = {} + fast_app.dependency_overrides[get_deposit_events_repository] = lambda: events_repository + + print(f"Current timestamp: {current_timestamp}") + async with fast_client as client: + resp = await client.get(f"rewards/budget/{alice.address}/upcoming") + assert resp.json() == {"upcomingBudget": "0"} + assert resp.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_returns_upcoming_budget_with_deposits( + fake_epochs_contract_factory: FakeEpochsContractCallable, + fake_epochs_subgraph_factory: FakeEpochsSubgraphCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, + fast_app: FastAPI, +): + """Should calculate and return upcoming budget based on user's deposits""" + # Given: a user and mocked epoch data + alice = await factories.users.get_or_create_alice() + current_epoch = 1 + current_timestamp = 1000 + epoch_start = 0 + deposit_amount = 100 * 10**18 # 100 tokens + + # Mock contracts and subgraph + fake_epochs_contract_factory( + FakeEpochsContractDetails(current_epoch=current_epoch) + ) + + # Mock subgraph response + fake_epochs_subgraph_factory([ + FakeEpochEventDetails( + epoch=current_epoch, + from_ts=epoch_start, + to_ts=2000, + ) + ]) + + events_repository = MagicMock(spec=GetDepositEventsRepository) + events_repository.get_all_users_events.return_value = { + alice.address: [ + DepositEvent( + user=alice.address, + type=EventType.LOCK, + timestamp=epoch_start + 100, + amount=deposit_amount, + deposit_before=0, + source=DepositSource.OCTANT, + ) + ] + } + fast_app.dependency_overrides[get_deposit_events_repository] = lambda: events_repository + fast_app.dependency_overrides[get_current_timestamp] = lambda: current_timestamp + + async with fast_client as client: + resp = await client.get(f"rewards/budget/{alice.address}/upcoming") + assert resp.status_code == HTTPStatus.OK + assert resp.json() != {"upcomingBudget": "0"} diff --git a/backend/v2/allocations/dependencies.py b/backend/v2/allocations/dependencies.py index df1ea54741..d2aca5e4ba 100644 --- a/backend/v2/allocations/dependencies.py +++ b/backend/v2/allocations/dependencies.py @@ -5,7 +5,7 @@ from v2.allocations.validators import SignatureVerifier from v2.core.dependencies import GetChainSettings, GetSession from v2.epochs.dependencies import GetEpochsSubgraph, GetOpenAllocationWindowEpochNumber -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimatorInAW from v2.projects.dependencies import GetProjectsContracts from v2.uniqueness_quotients.dependencies import GetUQScoreGetter @@ -30,7 +30,7 @@ async def get_allocator( signature_verifier: GetSignatureVerifier, uq_score_getter: GetUQScoreGetter, projects_contracts: GetProjectsContracts, - matched_rewards_estimator: GetMatchedRewardsEstimator, + matched_rewards_estimator: GetMatchedRewardsEstimatorInAW, ) -> Allocator: return Allocator( session, diff --git a/backend/v2/allocations/services.py b/backend/v2/allocations/services.py index a5b15dc9ef..46e1c8b06d 100644 --- a/backend/v2/allocations/services.py +++ b/backend/v2/allocations/services.py @@ -10,7 +10,7 @@ from v2.allocations.schemas import AllocationWithUserUQScore, UserAllocationRequest from v2.allocations.validators import SignatureVerifier from v2.matched_rewards.services import MatchedRewardsEstimator -from v2.project_rewards.capped_quadriatic import cqf_simulate_leverage +from v2.project_rewards.capped_quadratic import cqf_simulate_leverage from v2.projects.contracts import ProjectsContracts from v2.uniqueness_quotients.dependencies import UQScoreGetter from v2.users.repositories import get_user_by_address diff --git a/backend/v2/allocations/socket.py b/backend/v2/allocations/socket.py index fee236a31f..20dd00411e 100644 --- a/backend/v2/allocations/socket.py +++ b/backend/v2/allocations/socket.py @@ -26,7 +26,7 @@ get_open_allocation_window_epoch_number, ) from v2.matched_rewards.dependencies import ( - get_matched_rewards_estimator, + get_matched_rewards_estimator_only_in_aw, get_matched_rewards_estimator_settings, ) from v2.project_rewards.dependencies import get_project_rewards_estimator @@ -74,7 +74,7 @@ async def create_dependencies_on_connect() -> AsyncGenerator[ projects_contracts, get_projects_allocation_threshold_settings(), ) - estimated_matched_rewards = await get_matched_rewards_estimator( + estimated_matched_rewards = await get_matched_rewards_estimator_only_in_aw( epoch_number, session, epochs_subgraph, @@ -131,7 +131,7 @@ async def create_dependencies_on_allocate() -> AsyncGenerator[ projects_contracts, get_projects_allocation_threshold_settings(), ) - estimated_matched_rewards = await get_matched_rewards_estimator( + estimated_matched_rewards = await get_matched_rewards_estimator_only_in_aw( epoch_number, session, epochs_subgraph, diff --git a/backend/v2/core/dependencies.py b/backend/v2/core/dependencies.py index afd3dbe5ba..508138d5a0 100644 --- a/backend/v2/core/dependencies.py +++ b/backend/v2/core/dependencies.py @@ -10,6 +10,7 @@ from web3.middleware import async_geth_poa_middleware from app.shared.blockchain_types import ChainTypes +from v2.core.logic import compare_blockchain_types class OctantSettings(BaseSettings): @@ -110,9 +111,11 @@ class ChainSettings(OctantSettings): description="The chain id to use for the signature verification.", ) - @property - def is_mainnet(self) -> bool: - return self.chain_id == ChainTypes.MAINNET + is_mainnet: bool = Field( + default_factory=lambda: compare_blockchain_types( + Field(validation_alias="chain_id"), ChainTypes.MAINNET + ) + ) def get_chain_settings() -> ChainSettings: diff --git a/backend/v2/matched_rewards/dependencies.py b/backend/v2/matched_rewards/dependencies.py index cdafa0ed3f..668ec571e4 100644 --- a/backend/v2/matched_rewards/dependencies.py +++ b/backend/v2/matched_rewards/dependencies.py @@ -28,7 +28,7 @@ def get_matched_rewards_estimator_settings() -> MatchedRewardsEstimatorSettings: return MatchedRewardsEstimatorSettings() -async def get_matched_rewards_estimator( +async def get_matched_rewards_estimator_only_in_aw( epoch_number: GetOpenAllocationWindowEpochNumber, session: GetSession, epochs_subgraph: Annotated[EpochsSubgraph, Depends(get_epochs_subgraph)], @@ -47,7 +47,31 @@ async def get_matched_rewards_estimator( ) +async def get_matched_rewards_estimator( + epoch_number: int, + session: GetSession, + epochs_subgraph: Annotated[EpochsSubgraph, Depends(get_epochs_subgraph)], + settings: Annotated[ + MatchedRewardsEstimatorSettings, + Depends(get_matched_rewards_estimator_settings), + ], +) -> MatchedRewardsEstimator: + return MatchedRewardsEstimator( + session=session, + epochs_subgraph=epochs_subgraph, + tr_percent=settings.TR_PERCENT, + ire_percent=settings.IRE_PERCENT, + matched_rewards_percent=settings.MATCHED_REWARDS_PERCENT, + epoch_number=epoch_number, + ) + + +GetMatchedRewardsEstimatorInAW = Annotated[ + MatchedRewardsEstimator, + Depends(get_matched_rewards_estimator_only_in_aw), +] + GetMatchedRewardsEstimator = Annotated[ MatchedRewardsEstimator, Depends(get_matched_rewards_estimator), -] +] \ No newline at end of file diff --git a/backend/v2/project_rewards/capped_quadriatic.py b/backend/v2/project_rewards/capped_quadratic.py similarity index 91% rename from backend/v2/project_rewards/capped_quadriatic.py rename to backend/v2/project_rewards/capped_quadratic.py index 22751b3b17..00fbf2a959 100644 --- a/backend/v2/project_rewards/capped_quadriatic.py +++ b/backend/v2/project_rewards/capped_quadratic.py @@ -8,21 +8,21 @@ from v2.project_rewards.schemas import ProjectFundingSummaryV1 -class CappedQuadriaticFunding(NamedTuple): +class CappedQuadraticFunding(NamedTuple): project_fundings: dict[Address, ProjectFundingSummaryV1] - amounts_total: Decimal # Sum of all allocation amounts for all projects - matched_total: Decimal # Sum of all matched rewards for all projects + allocations_total_for_all_projects: Decimal # Sum of all allocation amounts for all projects + matched_total_for_all_projects: Decimal # Sum of all matched rewards for all projects MR_FUNDING_CAP_PERCENT = Decimal("0.2") -def capped_quadriatic_funding( +def capped_quadratic_funding( allocations: list[AllocationWithUserUQScore], matched_rewards: int, project_addresses: list[str], MR_FUNDING_CAP_PERCENT: Decimal = MR_FUNDING_CAP_PERCENT, -) -> CappedQuadriaticFunding: +) -> CappedQuadraticFunding: """ Calculate capped quadratic funding based on a list of allocations. @@ -33,7 +33,7 @@ def capped_quadriatic_funding( MR_FUNDING_CAP_PERCENT (float, optional): The maximum percentage of matched rewards that any single project can receive. Defaults to MR_FUNDING_CAP_PERCENT. Returns: - CappedQuadriaticFunding: A named tuple containing the total and per-project amounts and matched rewards. + CappedQuadraticFunding: A named tuple containing the total and per-project amounts and matched rewards. """ # Group allocations by project @@ -104,7 +104,7 @@ def capped_quadriatic_funding( for project_address in project_addresses } - return CappedQuadriaticFunding( + return CappedQuadraticFunding( project_fundings=project_fundings, amounts_total=amounts_total, matched_total=matched_total, @@ -121,8 +121,8 @@ def cqf_calculate_total_leverage(matched_rewards: int, total_allocated: int) -> def cqf_calculate_individual_leverage( new_allocations_amount: int, project_addresses: list[Address], - before_allocation: CappedQuadriaticFunding, - after_allocation: CappedQuadriaticFunding, + before_allocation: CappedQuadraticFunding, + after_allocation: CappedQuadraticFunding, ) -> float: """Calculate the leverage of a user's new allocations in capped quadratic funding. @@ -174,13 +174,13 @@ def cqf_simulate_leverage( ] # Calculate capped quadratic funding before and after the user's allocation - before_allocation = capped_quadriatic_funding( + before_allocation = capped_quadratic_funding( allocations_without_user, matched_rewards, project_addresses, MR_FUNDING_CAP_PERCENT, ) - after_allocation = capped_quadriatic_funding( + after_allocation = capped_quadratic_funding( allocations_without_user + new_allocations, matched_rewards, project_addresses, diff --git a/backend/v2/project_rewards/dependencies.py b/backend/v2/project_rewards/dependencies.py index fad325071e..f98bd6d933 100644 --- a/backend/v2/project_rewards/dependencies.py +++ b/backend/v2/project_rewards/dependencies.py @@ -3,7 +3,7 @@ from fastapi import Depends from v2.core.dependencies import GetSession from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimatorInAW from v2.project_rewards.services import ProjectRewardsEstimator from v2.projects.dependencies import GetProjectsContracts @@ -12,7 +12,7 @@ async def get_project_rewards_estimator( epoch_number: GetOpenAllocationWindowEpochNumber, session: GetSession, projects_contracts: GetProjectsContracts, - estimated_project_matched_rewards: GetMatchedRewardsEstimator, + estimated_project_matched_rewards: GetMatchedRewardsEstimatorInAW, ) -> ProjectRewardsEstimator: return ProjectRewardsEstimator( session=session, diff --git a/backend/v2/project_rewards/router.py b/backend/v2/project_rewards/router.py index 0b9f811b90..40ca5ac243 100644 --- a/backend/v2/project_rewards/router.py +++ b/backend/v2/project_rewards/router.py @@ -8,7 +8,7 @@ from app.engine.user.budget.with_ppf import UserBudgetWithPPF from app.modules.snapshots.pending.core import calculate_user_budgets from app.modules.staking.proceeds.core import estimate_staking_proceeds -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator, GetMatchedRewardsEstimatorInAW, get_matched_rewards_estimator from v2.deposits.dependencies import GetDepositEventsRepository from v2.glms.dependencies import GetGLMBalanceOfDeposits from v2.project_rewards.user_events import ( @@ -79,7 +79,7 @@ async def get_user_budget_for_epoch_v1( epoch_number, ) - if not budget: + if budget is None: response.status_code = status.HTTP_204_NO_CONTENT return None @@ -120,7 +120,6 @@ async def get_user_budget_for_upcoming_epoch_v1( # BEGIN: Calculate pending snapshot eth_proceeds = estimate_staking_proceeds(epoch_end - epoch_start) - # Based on all deposit events, calculate effective deposits events = await deposit_events_repository.get_all_users_events( epoch_number, epoch_start, epoch_end @@ -173,8 +172,8 @@ async def get_epoch_budgets_v1( async def get_estimated_budget_v1( session: GetSession, epoch_contracts: GetEpochsContracts, + # Parameters glm_balance: GetGLMBalanceOfDeposits, - matched_rewards_estimator: GetMatchedRewardsEstimator, request: EstimatedBudgetByEpochRequestV1, ) -> UserBudgetWithMatchedFundingResponseV1: """ @@ -222,13 +221,19 @@ async def get_estimated_budget_v1( # Matching fund is calculated based on the last epoch's leverage epoch_number = await epoch_contracts.get_finalized_epoch() - rewards_leverage = await get_rewards_leverage_v1( - session, epoch_contracts, epoch_number, matched_rewards_estimator - ) + + # Calculate the leverage based on the last finalized epoch + finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) + if finalized_snapshot is None: + raise MissingSnapshot() + matched_rewards = int(finalized_snapshot.matched_rewards) + + allocations_sum = await sum_allocations_by_epoch(session, epoch_number) + leverage = matched_rewards / allocations_sum if allocations_sum else 0 return UserBudgetWithMatchedFundingResponseV1( budget=epochs_budget, - matched_funding=int(epochs_budget * rewards_leverage.leverage), + matched_funding=int(epochs_budget * leverage), ) @@ -347,8 +352,9 @@ async def get_estimated_budget_by_days_v1( async def get_rewards_leverage_v1( session: GetSession, epochs_contracts: GetEpochsContracts, - epoch_number: int, matched_rewards_estimator: GetMatchedRewardsEstimator, + # Parameters + epoch_number: int, ) -> RewardsLeverageResponseV1: """ Returns leverage for a given epoch. @@ -357,8 +363,6 @@ async def get_rewards_leverage_v1( For pending and finalizing epochs it returns the leverage based on the estimated matched rewards of pending epoch. """ - print(f"Getting leverage for epoch {epoch_number}") - epoch_state = await get_epoch_state(session, epochs_contracts, epoch_number) # This operations only makes sense for finalized, finalizing and pending epochs diff --git a/backend/v2/project_rewards/schemas.py b/backend/v2/project_rewards/schemas.py index 314697e141..49e6cc5ffb 100644 --- a/backend/v2/project_rewards/schemas.py +++ b/backend/v2/project_rewards/schemas.py @@ -20,7 +20,7 @@ class EstimatedProjectRewardsResponseV1(OctantModel): class EstimatedBudgetByEpochRequestV1(OctantModel): number_of_epochs: int = Field( - ..., description="Number of epochs when GLM are locked", ge=0 + ..., description="Number of epochs when GLM are locked", gt=0 ) glm_amount: BigInteger = Field( ..., description="Amount of estimated GLM locked in wei", ge=0 @@ -42,7 +42,7 @@ class EstimatedBudgetByDaysRequestV1(OctantModel): @property def lock_duration_sec(self) -> int: - return self.days * 86400 # 24hours * 60minutes * 60seconds + return self.days * 24 * 60 * 60 class RewardsMerkleTreeLeafV1(OctantModel): diff --git a/backend/v2/project_rewards/services.py b/backend/v2/project_rewards/services.py index 41eccad474..422c2435fc 100644 --- a/backend/v2/project_rewards/services.py +++ b/backend/v2/project_rewards/services.py @@ -19,9 +19,9 @@ ) from v2.allocations.repositories import get_allocations_with_user_uqs from v2.matched_rewards.services import MatchedRewardsEstimator -from v2.project_rewards.capped_quadriatic import ( - CappedQuadriaticFunding, - capped_quadriatic_funding, +from v2.project_rewards.capped_quadratic import ( + CappedQuadraticFunding, + capped_quadratic_funding, ) from v2.projects.contracts import ProjectsContracts @@ -36,7 +36,7 @@ class ProjectRewardsEstimator: # Parameters epoch_number: int - async def get(self) -> CappedQuadriaticFunding: + async def get(self) -> CappedQuadraticFunding: # Gather all the necessary data for the calculation all_projects = await self.projects_contracts.get_project_addresses( self.epoch_number @@ -49,7 +49,7 @@ async def get(self) -> CappedQuadriaticFunding: ) # Calculate using the Capped Quadratic Funding formula - return capped_quadriatic_funding( + return capped_quadratic_funding( project_addresses=all_projects, allocations=allocations, matched_rewards=matched_rewards, From f533ca76ad1b8fb041f8a046793bf5fbeba84d3e Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 01:52:06 +0100 Subject: [PATCH 05/14] Small fixes and reformatting --- backend/app/context/epoch/block_range.py | 4 +- backend/tests/v2/factories/__init__.py | 3 + backend/tests/v2/factories/budgets.py | 7 +- .../tests/v2/factories/finalized_snapshots.py | 7 +- backend/tests/v2/factories/patrons.py | 52 +++++++ .../tests/v2/factories/pending_snapshots.py | 15 +- backend/tests/v2/factories/users.py | 2 +- .../project_rewards/test_estimated_budged.py | 87 ++++------- .../test_get_budget_for_epoch.py | 2 +- .../project_rewards/test_get_epoch_budgets.py | 28 ++-- .../test_get_rewards_leverage.py | 24 +-- .../test_get_unused_rewards.py | 142 ++++++++++++++++++ .../test_get_upcoming_budget.py | 69 +++++---- backend/v2/deposits/dependencies.py | 8 +- backend/v2/deposits/repositories.py | 101 ------------- backend/v2/deposits/services.py | 103 +++++++++++++ backend/v2/matched_rewards/dependencies.py | 2 +- .../v2/project_rewards/capped_quadratic.py | 4 +- backend/v2/project_rewards/router.py | 8 +- backend/v2/project_rewards/services.py | 76 ++++++++-- backend/v2/project_rewards/user_events.py | 120 --------------- backend/v2/sablier/dependencies.py | 2 +- backend/v2/sablier/subgraphs.py | 2 +- 23 files changed, 494 insertions(+), 374 deletions(-) create mode 100644 backend/tests/v2/factories/patrons.py create mode 100644 backend/tests/v2/project_rewards/test_get_unused_rewards.py create mode 100644 backend/v2/deposits/services.py delete mode 100644 backend/v2/project_rewards/user_events.py diff --git a/backend/app/context/epoch/block_range.py b/backend/app/context/epoch/block_range.py index 20af7a5111..95350c60c3 100644 --- a/backend/app/context/epoch/block_range.py +++ b/backend/app/context/epoch/block_range.py @@ -12,8 +12,8 @@ def get_blocks_range( if not with_block_range: return None, None - # start_block = get_block_num_from_ts(start_sec) if start_sec <= now_sec else None - # end_block = get_block_num_from_ts(end_sec) if end_sec <= now_sec else None + start_block = get_block_num_from_ts(start_sec) if start_sec <= now_sec else None + end_block = get_block_num_from_ts(end_sec) if end_sec <= now_sec else None start_block = None end_block = None diff --git a/backend/tests/v2/factories/__init__.py b/backend/tests/v2/factories/__init__.py index 4e5a0647ff..0ef06a28a1 100644 --- a/backend/tests/v2/factories/__init__.py +++ b/backend/tests/v2/factories/__init__.py @@ -7,6 +7,7 @@ from tests.v2.factories.budgets import BudgetFactorySet from tests.v2.factories.pending_snapshots import PendingEpochSnapshotFactorySet from tests.v2.factories.finalized_snapshots import FinalizedEpochSnapshotFactorySet +from tests.v2.factories.patrons import PatronModeEventFactorySet from dataclasses import dataclass @@ -29,6 +30,7 @@ class FactoriesAggregator: budgets: BudgetFactorySet pending_snapshots: PendingEpochSnapshotFactorySet finalized_snapshots: FinalizedEpochSnapshotFactorySet + patrons: PatronModeEventFactorySet def __init__(self, fast_session: AsyncSession): """ @@ -41,3 +43,4 @@ def __init__(self, fast_session: AsyncSession): self.budgets = BudgetFactorySet(fast_session) self.pending_snapshots = PendingEpochSnapshotFactorySet(fast_session) self.finalized_snapshots = FinalizedEpochSnapshotFactorySet(fast_session) + self.patrons = PatronModeEventFactorySet(fast_session) diff --git a/backend/tests/v2/factories/budgets.py b/backend/tests/v2/factories/budgets.py index a14a0877fe..dbb769ca75 100644 --- a/backend/tests/v2/factories/budgets.py +++ b/backend/tests/v2/factories/budgets.py @@ -1,7 +1,6 @@ import random from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory from factory import LazyAttribute -from sqlalchemy.ext.asyncio import AsyncSession from app.infrastructure.database.models import Budget, User from tests.v2.factories.base import FactorySetBase @@ -16,7 +15,9 @@ class Meta: user_id = None epoch = None - budget = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) # Random amount in wei + budget = LazyAttribute( + lambda _: str(random.randint(1, 1000) * 10**18) + ) # Random amount in wei class BudgetFactorySet(FactorySetBase): @@ -51,4 +52,4 @@ async def create( factory_kwargs["budget"] = str(amount) budget = await BudgetFactory.create(**factory_kwargs) - return budget \ No newline at end of file + return budget diff --git a/backend/tests/v2/factories/finalized_snapshots.py b/backend/tests/v2/factories/finalized_snapshots.py index c66d967687..0a2ee9713d 100644 --- a/backend/tests/v2/factories/finalized_snapshots.py +++ b/backend/tests/v2/factories/finalized_snapshots.py @@ -1,7 +1,6 @@ import random from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory from factory import LazyAttribute -from sqlalchemy.ext.asyncio import AsyncSession from app.infrastructure.database.models import FinalizedEpochSnapshot from tests.v2.factories.base import FactorySetBase @@ -17,7 +16,9 @@ class Meta: matched_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) patrons_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) leftover = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) - withdrawals_merkle_root = LazyAttribute(lambda _: "0x" + "".join(random.choices("0123456789abcdef", k=64))) + withdrawals_merkle_root = LazyAttribute( + lambda _: "0x" + "".join(random.choices("0123456789abcdef", k=64)) + ) total_withdrawals = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) @@ -63,4 +64,4 @@ async def create( factory_kwargs["total_withdrawals"] = str(total_withdrawals) snapshot = await FinalizedEpochSnapshotFactory.create(**factory_kwargs) - return snapshot \ No newline at end of file + return snapshot diff --git a/backend/tests/v2/factories/patrons.py b/backend/tests/v2/factories/patrons.py new file mode 100644 index 0000000000..a9cac8dc64 --- /dev/null +++ b/backend/tests/v2/factories/patrons.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory + +from app.infrastructure.database.models import PatronModeEvent, User +from tests.v2.factories.base import FactorySetBase +from tests.v2.factories.users import UserFactorySet +from v2.core.types import Address + + +class PatronModeEventFactory(AsyncSQLAlchemyFactory): + class Meta: + model = PatronModeEvent + sqlalchemy_session_persistence = "commit" + + user_address = None + patron_mode_enabled = True + created_at = datetime.now(timezone.utc) + + +class PatronModeEventFactorySet(FactorySetBase): + _factories = {"patron_event": PatronModeEventFactory} + + async def create( + self, + user: User | Address, + patron_mode_enabled: bool = True, + created_at: datetime | None = None, + ) -> PatronModeEvent: + """ + Create a patron mode event. + + Args: + user: The user or user address to create the event for + patron_mode_enabled: Whether patron mode is enabled (default: True) + created_at: Optional timestamp for when the event was created (default: now) + + Returns: + The created patron mode event + """ + if not isinstance(user, User): + user = await UserFactorySet(self.session).get_or_create(user) + + factory_kwargs = { + "user_address": user.address, + "patron_mode_enabled": patron_mode_enabled, + } + + if created_at is not None: + factory_kwargs["created_at"] = created_at + + event = await PatronModeEventFactory.create(**factory_kwargs) + return event diff --git a/backend/tests/v2/factories/pending_snapshots.py b/backend/tests/v2/factories/pending_snapshots.py index 9abaa33e71..5baa787ddd 100644 --- a/backend/tests/v2/factories/pending_snapshots.py +++ b/backend/tests/v2/factories/pending_snapshots.py @@ -1,7 +1,6 @@ import random from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory from factory import LazyAttribute -from sqlalchemy.ext.asyncio import AsyncSession from app.infrastructure.database.models import PendingEpochSnapshot from tests.v2.factories.base import FactorySetBase @@ -15,10 +14,14 @@ class Meta: epoch = None eth_proceeds = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) - total_effective_deposit = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + total_effective_deposit = LazyAttribute( + lambda _: str(random.randint(1, 1000) * 10**18) + ) locked_ratio = LazyAttribute(lambda _: str(random.randint(1, 100))) total_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) - vanilla_individual_rewards = LazyAttribute(lambda _: str(random.randint(1, 1000) * 10**18)) + vanilla_individual_rewards = LazyAttribute( + lambda _: str(random.randint(1, 1000) * 10**18) + ) operational_cost = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) ppf = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) community_fund = LazyAttribute(lambda _: str(random.randint(1, 100) * 10**18)) @@ -69,7 +72,9 @@ async def create( if total_rewards is not None: factory_kwargs["total_rewards"] = str(total_rewards) if vanilla_individual_rewards is not None: - factory_kwargs["vanilla_individual_rewards"] = str(vanilla_individual_rewards) + factory_kwargs["vanilla_individual_rewards"] = str( + vanilla_individual_rewards + ) if operational_cost is not None: factory_kwargs["operational_cost"] = str(operational_cost) if ppf is not None: @@ -78,4 +83,4 @@ async def create( factory_kwargs["community_fund"] = str(community_fund) snapshot = await PendingEpochSnapshotFactory.create(**factory_kwargs) - return snapshot \ No newline at end of file + return snapshot diff --git a/backend/tests/v2/factories/users.py b/backend/tests/v2/factories/users.py index 5f0b960b53..b12fd95a5c 100644 --- a/backend/tests/v2/factories/users.py +++ b/backend/tests/v2/factories/users.py @@ -20,7 +20,7 @@ class Meta: class UserFactorySet(FactorySetBase): _factories = {"user": UserFactory} - async def create(self, address: Address) -> User: + async def create(self, address: Address | None = None) -> User: factory_kwargs = {} if address is not None: diff --git a/backend/tests/v2/project_rewards/test_estimated_budged.py b/backend/tests/v2/project_rewards/test_estimated_budged.py index 9a38b870a0..5fc6544b24 100644 --- a/backend/tests/v2/project_rewards/test_estimated_budged.py +++ b/backend/tests/v2/project_rewards/test_estimated_budged.py @@ -1,14 +1,11 @@ -from unittest.mock import MagicMock import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from v2.matched_rewards.services import MatchedRewardsEstimator from v2.glms.dependencies import get_glm_balance_of_deposits from tests.v2.factories import FactoriesAggregator from tests.v2.fake_contracts.helpers import FakeEpochsContractDetails -from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory, FakeEpochsContractCallable -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimatorInAW, get_matched_rewards_estimator_only_in_aw +from tests.v2.fake_contracts.conftest import FakeEpochsContractCallable from fastapi import FastAPI @@ -36,17 +33,15 @@ async def test_estimates_budget_for_single_epoch( finalized_epoch=finalized_epoch, current_epoch=finalized_epoch + 1, pending_epoch=None, - future_epoch_props=(0, 0, epoch_start, epoch_duration) + future_epoch_props=(0, 0, epoch_start, epoch_duration), ) ) # Create pending epoch snapshot - pending_snapshot = await factories.pending_snapshots.create( - epoch=finalized_epoch - ) + await factories.pending_snapshots.create(epoch=finalized_epoch) # finalize epoch snapshot - finalized_snapshot = await factories.finalized_snapshots.create( + await factories.finalized_snapshots.create( epoch=finalized_epoch, ) @@ -58,20 +53,16 @@ async def test_estimates_budget_for_single_epoch( # rewards_estimator_mock.get.return_value = leverage # fast_app.dependency_overrides[get_matched_rewards_estimator] = lambda: rewards_estimator_mock - request_data = { - "glm_amount": str(glm_amount), - "number_of_epochs": 1 - } + request_data = {"glm_amount": str(glm_amount), "number_of_epochs": 1} async with fast_client as client: resp = await client.post("/rewards/estimated_budget", json=request_data) - - print("resp", resp.json()) + assert resp.status_code == 200 response = resp.json() assert response["budget"] != "0" assert response["matchedFunding"] == "0" - + @pytest.mark.asyncio async def test_estimates_budget_for_multiple_epochs( @@ -101,10 +92,8 @@ async def test_estimates_budget_for_multiple_epochs( epoch=finalized_epoch, ) - # Create pending epoch snapshot - pending_snapshot = await factories.pending_snapshots.create( - epoch=finalized_epoch - ) + # Create pending epoch snapshot + await factories.pending_snapshots.create(epoch=finalized_epoch) # finalize epoch snapshot finalized_snapshot = await factories.finalized_snapshots.create( @@ -115,7 +104,7 @@ async def test_estimates_budget_for_multiple_epochs( fake_epochs_contract_factory( FakeEpochsContractDetails( finalized_epoch=finalized_epoch, - future_epoch_props=(0, 0, epoch_start, epoch_duration) + future_epoch_props=(0, 0, epoch_start, epoch_duration), ) ) @@ -127,52 +116,42 @@ async def test_estimates_budget_for_multiple_epochs( # rewards_estimator_mock.get.return_value = leverage # fast_app.dependency_overrides[GetMatchedRewardsEstimatorInAW] = lambda: rewards_estimator_mock - request_data = { - "glm_amount": str(glm_amount), - "number_of_epochs": number_of_epochs - } + request_data = {"glm_amount": str(glm_amount), "number_of_epochs": number_of_epochs} async with fast_client as client: resp = await client.post("/rewards/estimated_budget", json=request_data) - print("result", resp.json()) - + assert resp.status_code == 200 result = resp.json() - + assert result["budget"] != "0" assert result["matchedFunding"] != "0" - leverage = int(finalized_snapshot.matched_rewards) / (int(alice_allocation.amount) + int(bob_allocation.amount)) + leverage = int(finalized_snapshot.matched_rewards) / ( + int(alice_allocation.amount) + int(bob_allocation.amount) + ) assert int(result["matchedFunding"]) == int(int(result["budget"]) * leverage) one_epoch_budget = int(result["budget"]) one_epoch_matched_funding = int(result["matchedFunding"]) # Test two epochs - request_data = { - "glm_amount": str(glm_amount), - "number_of_epochs": 2 - } + request_data = {"glm_amount": str(glm_amount), "number_of_epochs": 2} resp = await client.post("/rewards/estimated_budget", json=request_data) - + assert resp.status_code == 200 result = resp.json() - print("result", result) assert int(result["budget"]) != "0" assert int(result["matchedFunding"]) != "0" assert int(result["budget"]) == one_epoch_budget * 2 assert int(result["matchedFunding"]) == one_epoch_matched_funding * 2 - # Test zero GLM amount - request_data = { - "glm_amount": str(0), - "number_of_epochs": 1 - } + request_data = {"glm_amount": str(0), "number_of_epochs": 1} resp = await client.post("/rewards/estimated_budget", json=request_data) - + assert resp.status_code == 200 result = resp.json() assert int(result["budget"]) == 0 @@ -186,22 +165,22 @@ async def test_validates_request_parameters( """Should validate request parameters""" async with fast_client as client: # Test negative GLM amount - resp = await client.post("/rewards/estimated_budget", json={ - "glm_amount": "-1000", - "number_of_epochs": 1 - }) + resp = await client.post( + "/rewards/estimated_budget", + json={"glm_amount": "-1000", "number_of_epochs": 1}, + ) assert resp.status_code == 422 # Test zero epochs - resp = await client.post("/rewards/estimated_budget", json={ - "glm_amount": "1000", - "number_of_epochs": 0 - }) + resp = await client.post( + "/rewards/estimated_budget", + json={"glm_amount": "1000", "number_of_epochs": 0}, + ) assert resp.status_code == 422 # Test negative epochs - resp = await client.post("/rewards/estimated_budget", json={ - "glm_amount": "1000", - "number_of_epochs": -1 - }) - assert resp.status_code == 422 \ No newline at end of file + resp = await client.post( + "/rewards/estimated_budget", + json={"glm_amount": "1000", "number_of_epochs": -1}, + ) + assert resp.status_code == 422 diff --git a/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py b/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py index 776c5a26f8..a76122f602 100644 --- a/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py +++ b/backend/tests/v2/project_rewards/test_get_budget_for_epoch.py @@ -58,7 +58,7 @@ async def test_returns_zero_budget_when_amount_is_zero( # Given: a user with zero budget alice = await factories.users.get_or_create_alice() - budget = await factories.budgets.create(user=alice, epoch=1, amount=0) + await factories.budgets.create(user=alice, epoch=1, amount=0) async with fast_client as client: resp = await client.get(f"rewards/budget/{alice.address}/epoch/1") diff --git a/backend/tests/v2/project_rewards/test_get_epoch_budgets.py b/backend/tests/v2/project_rewards/test_get_epoch_budgets.py index 46f7ce52c0..9c73c6f9c1 100644 --- a/backend/tests/v2/project_rewards/test_get_epoch_budgets.py +++ b/backend/tests/v2/project_rewards/test_get_epoch_budgets.py @@ -10,7 +10,7 @@ async def test_returns_empty_budgets_when_no_users( fast_session: AsyncSession, ): """Should return empty budgets list when no users have budgets for the epoch""" - + async with fast_client as client: resp = await client.get("/rewards/budgets/epoch/1") assert resp.status_code == 200 @@ -24,7 +24,7 @@ async def test_returns_all_user_budgets_for_epoch( factories: FactoriesAggregator, ): """Should return budgets for all users in the given epoch""" - + # Given: multiple users with budgets in different epochs alice = await factories.users.get_or_create_alice() bob = await factories.users.get_or_create_bob() @@ -34,7 +34,7 @@ async def test_returns_all_user_budgets_for_epoch( alice_budget1 = await factories.budgets.create(user=alice, epoch=1, amount=100) bob_budget1 = await factories.budgets.create(user=bob, epoch=1, amount=200) charlie_budget1 = await factories.budgets.create(user=charlie, epoch=1, amount=300) - + # Create budgets for a different epoch await factories.budgets.create(user=alice, epoch=2, amount=400) await factories.budgets.create(user=bob, epoch=2, amount=500) @@ -42,11 +42,11 @@ async def test_returns_all_user_budgets_for_epoch( async with fast_client as client: # When: requesting budgets for epoch 1 resp = await client.get("/rewards/budgets/epoch/1") - + # Then: should return all budgets for epoch 1 assert resp.status_code == 200 budgets = resp.json()["budgets"] - + # Convert to set for order-independent comparison expected_budgets = [ {"address": alice.address, "amount": str(alice_budget1.budget)}, @@ -66,18 +66,18 @@ async def test_returns_empty_budgets_for_future_epoch( factories: FactoriesAggregator, ): """Should return empty budgets list for an epoch with no budgets""" - + # Given: users with budgets in epoch 1 alice = await factories.users.get_or_create_alice() bob = await factories.users.get_or_create_bob() - + await factories.budgets.create(user=alice, epoch=1, amount=100) await factories.budgets.create(user=bob, epoch=1, amount=200) async with fast_client as client: # When: requesting budgets for epoch 2 (which has no budgets) resp = await client.get("/rewards/budgets/epoch/2") - + # Then: should return empty list assert resp.status_code == 200 assert resp.json() == {"budgets": []} @@ -90,20 +90,20 @@ async def test_includes_zero_budgets( factories: FactoriesAggregator, ): """Should include users with zero budgets in the response""" - + # Given: users with zero and non-zero budgets alice = await factories.users.get_or_create_alice() bob = await factories.users.get_or_create_bob() - + alice_budget = await factories.budgets.create(user=alice, epoch=1, amount=100) - bob_budget = await factories.budgets.create(user=bob, epoch=1, amount=0) + await factories.budgets.create(user=bob, epoch=1, amount=0) async with fast_client as client: resp = await client.get("/rewards/budgets/epoch/1") - + assert resp.status_code == 200 budgets = resp.json()["budgets"] - + expected_budgets = [ {"address": alice.address, "amount": str(alice_budget.budget)}, {"address": bob.address, "amount": "0"}, @@ -111,4 +111,4 @@ async def test_includes_zero_budgets( assert len(budgets) == 2 assert {(b["address"], b["amount"]) for b in budgets} == { (b["address"], b["amount"]) for b in expected_budgets - } \ No newline at end of file + } diff --git a/backend/tests/v2/project_rewards/test_get_rewards_leverage.py b/backend/tests/v2/project_rewards/test_get_rewards_leverage.py index 3fe0d40093..c22824f20b 100644 --- a/backend/tests/v2/project_rewards/test_get_rewards_leverage.py +++ b/backend/tests/v2/project_rewards/test_get_rewards_leverage.py @@ -5,14 +5,12 @@ from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from app.context.epoch_state import EpochState -from app.exceptions import InvalidEpoch, MissingSnapshot, NotImplementedForGivenEpochState +from app.exceptions import InvalidEpoch, NotImplementedForGivenEpochState from v2.matched_rewards.services import MatchedRewardsEstimator from tests.v2.factories import FactoriesAggregator from tests.v2.fake_contracts.helpers import FakeEpochsContractDetails from tests.v2.fake_contracts.conftest import FakeEpochsContractCallable -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator, get_matched_rewards_estimator -from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory, FakeEpochsContractCallable +from v2.matched_rewards.dependencies import get_matched_rewards_estimator @pytest.mark.asyncio @@ -36,7 +34,7 @@ async def test_returns_leverage_for_finalized_epoch( ) # Create pending snapshot - pending_snapshot = await factories.pending_snapshots.create( + await factories.pending_snapshots.create( epoch=epoch_number, ) @@ -56,7 +54,9 @@ async def test_returns_leverage_for_finalized_epoch( resp = await client.get(f"rewards/leverage/{epoch_number}") assert resp.status_code == HTTPStatus.OK resp_leverage = int(resp.json()["leverage"]) - expected_leverage = int(int(finalized_snapshot.matched_rewards) / int(alice_allocation.amount)) + expected_leverage = int( + int(finalized_snapshot.matched_rewards) / int(alice_allocation.amount) + ) assert resp_leverage == expected_leverage @@ -83,7 +83,7 @@ async def test_returns_leverage_for_pending_epoch( ) # Create pending snapshot - pending_snapshot = await factories.pending_snapshots.create( + await factories.pending_snapshots.create( epoch=epoch_number, ) @@ -97,7 +97,9 @@ async def test_returns_leverage_for_pending_epoch( # Mock rewards estimator rewards_estimator = MagicMock(spec=MatchedRewardsEstimator) rewards_estimator.get.return_value = estimated_rewards - fast_app.dependency_overrides[get_matched_rewards_estimator] = lambda: rewards_estimator + fast_app.dependency_overrides[ + get_matched_rewards_estimator + ] = lambda: rewards_estimator async with fast_client as client: resp = await client.get(f"rewards/leverage/{epoch_number}") @@ -126,12 +128,12 @@ async def test_returns_zero_leverage_when_no_allocations( ) # Create pending snapshot - pending_snapshot = await factories.pending_snapshots.create( + await factories.pending_snapshots.create( epoch=epoch_number, ) # Create finalized snapshot - finalized_snapshot = await factories.finalized_snapshots.create( + await factories.finalized_snapshots.create( epoch=epoch_number, matched_rewards=matched_rewards, ) @@ -188,4 +190,4 @@ async def test_raises_error_for_invalid_epoch_state( async with fast_client as client: resp = await client.get(f"rewards/leverage/{epoch_number}") assert resp.status_code == HTTPStatus.BAD_REQUEST - assert resp.json() == {"message": NotImplementedForGivenEpochState.description} \ No newline at end of file + assert resp.json() == {"message": NotImplementedForGivenEpochState.description} diff --git a/backend/tests/v2/project_rewards/test_get_unused_rewards.py b/backend/tests/v2/project_rewards/test_get_unused_rewards.py new file mode 100644 index 0000000000..439c145c9d --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_unused_rewards.py @@ -0,0 +1,142 @@ +import pytest +from http import HTTPStatus +from datetime import datetime, timezone +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.v2.factories import FactoriesAggregator +from tests.v2.fake_subgraphs.conftest import FakeEpochsSubgraphCallable +from tests.v2.fake_subgraphs.helpers import FakeEpochEventDetails + + +@pytest.mark.asyncio +async def test_returns_empty_when_no_budgets( + fake_epochs_subgraph_factory: FakeEpochsSubgraphCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return empty list and zero value when there are no budgets""" + # Given: an epoch with no budgets + epoch_number = 1 + + # Mock subgraph response + fake_epochs_subgraph_factory( + [ + FakeEpochEventDetails( + epoch=epoch_number, + ) + ] + ) + + async with fast_client as client: + resp = await client.get(f"rewards/unused/{epoch_number}") + assert resp.status_code == HTTPStatus.OK + assert resp.json() == { + "addresses": [], + "value": "0", + } + + +@pytest.mark.asyncio +async def test_returns_unused_budgets_excluding_donors_and_patrons( + fake_epochs_subgraph_factory: FakeEpochsSubgraphCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return budgets excluding donors and patrons""" + # Given: an epoch with various users + epoch_number = 1 + + # Mock subgraph response + fake_epochs_subgraph_factory( + [ + FakeEpochEventDetails( + epoch=epoch_number, + ) + ] + ) + + # Create users with different roles + alice = await factories.users.get_or_create_alice() # Will be a donor + bob = await factories.users.get_or_create_bob() # Will be a patron + charlie = await factories.users.get_or_create_charlie() # Will have unused budget + dave = await factories.users.create() # Will have unused budget + + # Create budgets for all users + await factories.budgets.create(user=alice, epoch=epoch_number, amount=100) + await factories.budgets.create(user=bob, epoch=epoch_number, amount=200) + charlie_budget = await factories.budgets.create( + user=charlie, epoch=epoch_number, amount=300 + ) + dave_budget = await factories.budgets.create( + user=dave, epoch=epoch_number, amount=400 + ) + + # Make Alice a donor by creating an allocation + await factories.allocations.create(user=alice, epoch=epoch_number) + + # Make Bob a patron + await factories.patrons.create( + user=bob, + patron_mode_enabled=True, + created_at=datetime.fromtimestamp(1500, timezone.utc), + ) + + async with fast_client as client: + resp = await client.get(f"rewards/unused/{epoch_number}") + assert resp.status_code == HTTPStatus.OK + + result = resp.json() + assert sorted(result["addresses"]) == sorted([charlie.address, dave.address]) + assert result["value"] == str( + int(charlie_budget.budget) + int(dave_budget.budget) + ) + + +@pytest.mark.asyncio +async def test_returns_empty_when_all_users_are_donors_or_patrons( + fake_epochs_subgraph_factory: FakeEpochsSubgraphCallable, + fast_client: AsyncClient, + fast_session: AsyncSession, + factories: FactoriesAggregator, +): + """Should return empty when all users with budgets are either donors or patrons""" + # Given: an epoch where all users are donors or patrons + epoch_number = 1 + + # Mock subgraph response + fake_epochs_subgraph_factory( + [ + FakeEpochEventDetails( + epoch=epoch_number, + ) + ] + ) + + # Create users + alice = await factories.users.get_or_create_alice() # Will be a donor + bob = await factories.users.get_or_create_bob() # Will be a patron + + # Create budgets + await factories.budgets.create(user=alice, epoch=epoch_number) + await factories.budgets.create(user=bob, epoch=epoch_number) + + # Make Alice a donor + await factories.allocations.create(user=alice, epoch=epoch_number) + + # Make Bob a patron + await factories.patrons.create( + user=bob, + patron_mode_enabled=True, + created_at=datetime.fromtimestamp(1500, timezone.utc), + ) + + async with fast_client as client: + resp = await client.get(f"rewards/unused/{epoch_number}") + assert resp.status_code == HTTPStatus.OK + assert resp.json() == { + "addresses": [], + "value": "0", + } diff --git a/backend/tests/v2/project_rewards/test_get_upcoming_budget.py b/backend/tests/v2/project_rewards/test_get_upcoming_budget.py index cfad796046..eb9febe031 100644 --- a/backend/tests/v2/project_rewards/test_get_upcoming_budget.py +++ b/backend/tests/v2/project_rewards/test_get_upcoming_budget.py @@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.engine.user.effective_deposit import DepositEvent, DepositSource, EventType -from v2.deposits.dependencies import GetDepositEventsRepository, get_deposit_events_repository +from v2.deposits.dependencies import ( + GetDepositEventsRepository, + get_deposit_events_repository, +) from tests.v2.factories import FactoriesAggregator from tests.v2.fake_contracts.helpers import FakeEpochsContractDetails -from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory, FakeEpochsContractCallable -from tests.v2.fake_subgraphs.conftest import fake_epochs_subgraph_factory, FakeEpochsSubgraphCallable +from tests.v2.fake_contracts.conftest import FakeEpochsContractCallable +from tests.v2.fake_subgraphs.conftest import FakeEpochsSubgraphCallable from tests.v2.fake_subgraphs.helpers import FakeEpochEventDetails from fastapi import FastAPI from v2.core.dependencies import get_current_timestamp @@ -28,28 +31,28 @@ async def test_returns_upcoming_budget_with_no_deposits( # Given: a user and mocked epoch data alice = await factories.users.get_or_create_alice() current_epoch = 1 - current_timestamp = 1000 epoch_start = 0 # Mock contracts and subgraph - fake_epochs_contract_factory( - FakeEpochsContractDetails(current_epoch=current_epoch) - ) - + fake_epochs_contract_factory(FakeEpochsContractDetails(current_epoch=current_epoch)) + # Mock subgraph response - fake_epochs_subgraph_factory([ - FakeEpochEventDetails( - epoch=current_epoch, - from_ts=epoch_start, - to_ts=2000, - ) - ]) + fake_epochs_subgraph_factory( + [ + FakeEpochEventDetails( + epoch=current_epoch, + from_ts=epoch_start, + to_ts=2000, + ) + ] + ) events_repository = MagicMock(spec=GetDepositEventsRepository) events_repository.get_all_users_events.return_value = {} - fast_app.dependency_overrides[get_deposit_events_repository] = lambda: events_repository + fast_app.dependency_overrides[ + get_deposit_events_repository + ] = lambda: events_repository - print(f"Current timestamp: {current_timestamp}") async with fast_client as client: resp = await client.get(f"rewards/budget/{alice.address}/upcoming") assert resp.json() == {"upcomingBudget": "0"} @@ -74,18 +77,18 @@ async def test_returns_upcoming_budget_with_deposits( deposit_amount = 100 * 10**18 # 100 tokens # Mock contracts and subgraph - fake_epochs_contract_factory( - FakeEpochsContractDetails(current_epoch=current_epoch) - ) - + fake_epochs_contract_factory(FakeEpochsContractDetails(current_epoch=current_epoch)) + # Mock subgraph response - fake_epochs_subgraph_factory([ - FakeEpochEventDetails( - epoch=current_epoch, - from_ts=epoch_start, - to_ts=2000, - ) - ]) + fake_epochs_subgraph_factory( + [ + FakeEpochEventDetails( + epoch=current_epoch, + from_ts=epoch_start, + to_ts=2000, + ) + ] + ) events_repository = MagicMock(spec=GetDepositEventsRepository) events_repository.get_all_users_events.return_value = { @@ -93,17 +96,19 @@ async def test_returns_upcoming_budget_with_deposits( DepositEvent( user=alice.address, type=EventType.LOCK, - timestamp=epoch_start + 100, + timestamp=epoch_start + 100, amount=deposit_amount, deposit_before=0, source=DepositSource.OCTANT, - ) + ) ] } - fast_app.dependency_overrides[get_deposit_events_repository] = lambda: events_repository + fast_app.dependency_overrides[ + get_deposit_events_repository + ] = lambda: events_repository fast_app.dependency_overrides[get_current_timestamp] = lambda: current_timestamp async with fast_client as client: resp = await client.get(f"rewards/budget/{alice.address}/upcoming") assert resp.status_code == HTTPStatus.OK - assert resp.json() != {"upcomingBudget": "0"} + assert resp.json() != {"upcomingBudget": "0"} diff --git a/backend/v2/deposits/dependencies.py b/backend/v2/deposits/dependencies.py index c0c3509492..e1a350fcf6 100644 --- a/backend/v2/deposits/dependencies.py +++ b/backend/v2/deposits/dependencies.py @@ -5,7 +5,7 @@ SABLIER_UNLOCK_GRACE_PERIOD_24_HRS, TEST_SABLIER_UNLOCK_GRACE_PERIOD_15_MIN, ) -from v2.deposits.repositories import DepositEventsRepository +from v2.deposits.services import DepositEventsStore from v2.epochs.dependencies import GetEpochsSubgraph from v2.sablier.dependencies import GetSablierSubgraph from v2.core.dependencies import GetChainSettings, GetSession, OctantSettings, Web3 @@ -31,14 +31,14 @@ def get_deposit_events_repository( epochs_subgraph: GetEpochsSubgraph, sublier_subgraph: GetSablierSubgraph, chain_settings: GetChainSettings, -) -> DepositEventsRepository: +) -> DepositEventsStore: sablier_unlock_grace_period = ( SABLIER_UNLOCK_GRACE_PERIOD_24_HRS if chain_settings.is_mainnet else TEST_SABLIER_UNLOCK_GRACE_PERIOD_15_MIN ) - return DepositEventsRepository( + return DepositEventsStore( session, epochs_subgraph, sublier_subgraph, @@ -49,5 +49,5 @@ def get_deposit_events_repository( # Annotated dependencies GetDepositsContracts = Annotated[DepositsContracts, Depends(get_deposits_contracts)] GetDepositEventsRepository = Annotated[ - DepositEventsRepository, Depends(get_deposit_events_repository) + DepositEventsStore, Depends(get_deposit_events_repository) ] diff --git a/backend/v2/deposits/repositories.py b/backend/v2/deposits/repositories.py index c9ad6bdc91..b6d9083e64 100644 --- a/backend/v2/deposits/repositories.py +++ b/backend/v2/deposits/repositories.py @@ -1,24 +1,10 @@ -from itertools import groupby -from operator import attrgetter from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.engine.user.effective_deposit import ( - DepositEvent, - EventType, -) from app.infrastructure.database.models import Deposit -from app.modules.common.sablier_events_mapper import ( - FlattenStrategy, - flatten_sablier_events, - process_to_locks_and_unlocks, -) -from app.modules.user.events_generator.core import unify_deposit_balances from v2.core.types import Address from v2.core.transformers import transform_to_checksum_address -from v2.epochs.subgraphs import EpochsSubgraph -from v2.sablier.subgraphs import SablierSubgraph async def get_all_deposit_events_for_epoch( @@ -34,90 +20,3 @@ async def get_all_deposit_events_for_epoch( return { transform_to_checksum_address(result.user.address): result for result in results } - - -class DepositEventsRepository: - def __init__( - self, - session: AsyncSession, - epochs_subgraph: EpochsSubgraph, - sablier_subgraph: SablierSubgraph, - sablier_unlock_grace_period: int, - ): - self.session = session - self.epochs_subgraph = epochs_subgraph - self.sablier_subgraph = sablier_subgraph - self.sablier_unlock_grace_period = sablier_unlock_grace_period - - async def get_all_users_events( - self, - epoch_number: int, - start_sec: int, - end_sec: int, - ) -> dict[str, list[DepositEvent]]: - """ - Returns all user events (LOCK, UNLOCK) for a given epoch. - """ - - # Get all locked amounts for the previous epoch - epoch_start_locked_amounts = await get_all_deposit_events_for_epoch( - self.session, epoch_number - 1 - ) - epoch_start_events = [ - DepositEvent( - user=user, - type=EventType.LOCK, - timestamp=start_sec, - amount=0, # it is not a deposit in fact - deposit_before=int(deposit.epoch_end_deposit), - ) - for user, deposit in epoch_start_locked_amounts.items() - ] - - sablier_streams = await self.sablier_subgraph.get_all_streams_history() - mapped_streams = process_to_locks_and_unlocks( - sablier_streams, from_timestamp=start_sec, to_timestamp=end_sec - ) - # print("Mapped streams", mapped_streams) - epoch_events = [] - epoch_events += flatten_sablier_events(mapped_streams, FlattenStrategy.ALL) - epoch_events += await self.epochs_subgraph.fetch_locks_by_timestamp_range( - start_sec, end_sec - ) - epoch_events += await self.epochs_subgraph.fetch_unlocks_by_timestamp_range( - start_sec, end_sec - ) - - epoch_events = [DepositEvent.from_dict(event) for event in epoch_events] - sorted_events = sorted(epoch_events, key=attrgetter("user", "timestamp")) - - user_events = { - k: list(g) for k, g in groupby(sorted_events, key=attrgetter("user")) - } - - for event in epoch_start_events: - if event.user in user_events: - user_events[event.user].insert(0, event) - else: - user_events[event.user] = [event] - - epoch_start_users = list(map(attrgetter("user"), epoch_start_events)) - for user_address in user_events: - if user_address not in epoch_start_users: - user_events[user_address].insert( - 0, - DepositEvent( - user_address, - EventType.LOCK, - timestamp=start_sec, - amount=0, - deposit_before=0, - ), - ) - - user_events[user_address] = unify_deposit_balances( - user_events[user_address], - self.sablier_unlock_grace_period, - ) - - return user_events diff --git a/backend/v2/deposits/services.py b/backend/v2/deposits/services.py new file mode 100644 index 0000000000..4b37dd10e4 --- /dev/null +++ b/backend/v2/deposits/services.py @@ -0,0 +1,103 @@ +from itertools import groupby +from operator import attrgetter +from sqlalchemy.ext.asyncio import AsyncSession + +from app.engine.user.effective_deposit import ( + DepositEvent, + EventType, +) +from app.modules.common.sablier_events_mapper import ( + FlattenStrategy, + flatten_sablier_events, + process_to_locks_and_unlocks, +) +from app.modules.user.events_generator.core import unify_deposit_balances +from v2.deposits.repositories import get_all_deposit_events_for_epoch +from v2.epochs.subgraphs import EpochsSubgraph +from v2.sablier.subgraphs import SablierSubgraph + + +class DepositEventsStore: + def __init__( + self, + session: AsyncSession, + epochs_subgraph: EpochsSubgraph, + sablier_subgraph: SablierSubgraph, + sablier_unlock_grace_period: int, + ): + self.session = session + self.epochs_subgraph = epochs_subgraph + self.sablier_subgraph = sablier_subgraph + self.sablier_unlock_grace_period = sablier_unlock_grace_period + + async def get_all_users_events( + self, + epoch_number: int, + start_sec: int, + end_sec: int, + ) -> dict[str, list[DepositEvent]]: + """ + Returns all user events (LOCK, UNLOCK) for a given epoch. + """ + + # Get all locked amounts for the previous epoch + epoch_start_locked_amounts = await get_all_deposit_events_for_epoch( + self.session, epoch_number - 1 + ) + epoch_start_events = [ + DepositEvent( + user=user, + type=EventType.LOCK, + timestamp=start_sec, + amount=0, # it is not a deposit in fact + deposit_before=int(deposit.epoch_end_deposit), + ) + for user, deposit in epoch_start_locked_amounts.items() + ] + + sablier_streams = await self.sablier_subgraph.get_all_streams_history() + mapped_streams = process_to_locks_and_unlocks( + sablier_streams, from_timestamp=start_sec, to_timestamp=end_sec + ) + epoch_events = [] + epoch_events += flatten_sablier_events(mapped_streams, FlattenStrategy.ALL) + epoch_events += await self.epochs_subgraph.fetch_locks_by_timestamp_range( + start_sec, end_sec + ) + epoch_events += await self.epochs_subgraph.fetch_unlocks_by_timestamp_range( + start_sec, end_sec + ) + + epoch_events = [DepositEvent.from_dict(event) for event in epoch_events] + sorted_events = sorted(epoch_events, key=attrgetter("user", "timestamp")) + + user_events = { + k: list(g) for k, g in groupby(sorted_events, key=attrgetter("user")) + } + + for event in epoch_start_events: + if event.user in user_events: + user_events[event.user].insert(0, event) + else: + user_events[event.user] = [event] + + epoch_start_users = list(map(attrgetter("user"), epoch_start_events)) + for user_address in user_events: + if user_address not in epoch_start_users: + user_events[user_address].insert( + 0, + DepositEvent( + user_address, + EventType.LOCK, + timestamp=start_sec, + amount=0, + deposit_before=0, + ), + ) + + user_events[user_address] = unify_deposit_balances( + user_events[user_address], + self.sablier_unlock_grace_period, + ) + + return user_events diff --git a/backend/v2/matched_rewards/dependencies.py b/backend/v2/matched_rewards/dependencies.py index 668ec571e4..3abf80a79b 100644 --- a/backend/v2/matched_rewards/dependencies.py +++ b/backend/v2/matched_rewards/dependencies.py @@ -74,4 +74,4 @@ async def get_matched_rewards_estimator( GetMatchedRewardsEstimator = Annotated[ MatchedRewardsEstimator, Depends(get_matched_rewards_estimator), -] \ No newline at end of file +] diff --git a/backend/v2/project_rewards/capped_quadratic.py b/backend/v2/project_rewards/capped_quadratic.py index 00fbf2a959..996cc6986b 100644 --- a/backend/v2/project_rewards/capped_quadratic.py +++ b/backend/v2/project_rewards/capped_quadratic.py @@ -106,8 +106,8 @@ def capped_quadratic_funding( return CappedQuadraticFunding( project_fundings=project_fundings, - amounts_total=amounts_total, - matched_total=matched_total, + allocations_total_for_all_projects=amounts_total, + matched_total_for_all_projects=matched_total, ) diff --git a/backend/v2/project_rewards/router.py b/backend/v2/project_rewards/router.py index 40ca5ac243..fd6838e835 100644 --- a/backend/v2/project_rewards/router.py +++ b/backend/v2/project_rewards/router.py @@ -8,12 +8,11 @@ from app.engine.user.budget.with_ppf import UserBudgetWithPPF from app.modules.snapshots.pending.core import calculate_user_budgets from app.modules.staking.proceeds.core import estimate_staking_proceeds -from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator, GetMatchedRewardsEstimatorInAW, get_matched_rewards_estimator +from v2.matched_rewards.dependencies import ( + GetMatchedRewardsEstimator, +) from v2.deposits.dependencies import GetDepositEventsRepository from v2.glms.dependencies import GetGLMBalanceOfDeposits -from v2.project_rewards.user_events import ( - calculate_effective_deposits, -) from v2.allocations.repositories import ( get_donors_for_epoch, sum_allocations_by_epoch, @@ -37,6 +36,7 @@ ) from v2.core.dependencies import GetCurrentTimestamp, GetSession from v2.project_rewards.services import ( + calculate_effective_deposits, calculate_octant_rewards, calculate_user_budget, get_rewards_merkle_tree_for_epoch, diff --git a/backend/v2/project_rewards/services.py b/backend/v2/project_rewards/services.py index 422c2435fc..6ab93bdd6d 100644 --- a/backend/v2/project_rewards/services.py +++ b/backend/v2/project_rewards/services.py @@ -8,10 +8,6 @@ from app.engine.user.budget.with_ppf import UserBudgetWithPPF from app.modules.dto import OctantRewardsDTO from app.modules.octant_rewards.core import calculate_rewards -from v2.project_rewards.user_events import ( - calculate_effective_deposits, - simulate_user_events, -) from v2.project_rewards.repositories import get_rewards_for_epoch from v2.project_rewards.schemas import ( RewardsMerkleTreeLeafV1, @@ -26,6 +22,60 @@ from v2.projects.contracts import ProjectsContracts +from typing import Dict + +from app.engine.user.effective_deposit import ( + DepositEvent, + EventType, + UserDeposit, + UserEffectiveDepositPayload, +) +from app.engine.user.effective_deposit.weighted_average.default import ( + DefaultWeightedAverageEffectiveDeposit, +) + + +def calculate_effective_deposits( + start_sec: int, + end_sec: int, + events: Dict[str, list[DepositEvent]], +) -> tuple[list[UserDeposit], int]: + # TODO: We can do this better and nicer + effective_deposit_calculator = DefaultWeightedAverageEffectiveDeposit() + payload = UserEffectiveDepositPayload( + epoch_start=start_sec, + epoch_end=end_sec, + lock_events_by_addr=events, + ) + + return effective_deposit_calculator.calculate_users_effective_deposits(payload) + + +def simulate_user_events( + end_sec: int, lock_duration: int, remaining_sec: int, glm_amount: int +) -> list[DepositEvent]: + user_events = [ + DepositEvent( + user=ZERO_ADDRESS, + type=EventType.LOCK, + timestamp=end_sec - remaining_sec, + amount=glm_amount, + deposit_before=0, + ) + ] + if lock_duration < remaining_sec: + user_events.append( + DepositEvent( + user=ZERO_ADDRESS, + type=EventType.UNLOCK, + timestamp=end_sec - remaining_sec + lock_duration, + amount=glm_amount, + deposit_before=glm_amount, + ) + ) + return user_events + + @dataclass class ProjectRewardsEstimator: # Dependencies @@ -113,18 +163,16 @@ def calculate_octant_rewards( settings = OctantRewardsSettings() rewards = calculate_rewards(settings, eth_proceeds, total_effective_deposit) - # fmt: off return OctantRewardsDTO( - staking_proceeds = eth_proceeds, - locked_ratio = rewards.locked_ratio, - total_effective_deposit = total_effective_deposit, - total_rewards = rewards.total_rewards, - vanilla_individual_rewards = rewards.vanilla_individual_rewards, - operational_cost = rewards.operational_cost, - ppf = rewards.ppf_value, - community_fund = rewards.community_fund, + staking_proceeds=eth_proceeds, + locked_ratio=rewards.locked_ratio, + total_effective_deposit=total_effective_deposit, + total_rewards=rewards.total_rewards, + vanilla_individual_rewards=rewards.vanilla_individual_rewards, + operational_cost=rewards.operational_cost, + ppf=rewards.ppf_value, + community_fund=rewards.community_fund, ) - # fmt: on def simulate_user_effective_deposits( diff --git a/backend/v2/project_rewards/user_events.py b/backend/v2/project_rewards/user_events.py deleted file mode 100644 index 4d28d8f000..0000000000 --- a/backend/v2/project_rewards/user_events.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Dict - -from app.engine.octant_rewards import OctantRewardsSettings -from app.engine.user.budget.with_ppf import UserBudgetWithPPF -from app.engine.user.effective_deposit import ( - DepositEvent, - EventType, - UserDeposit, - UserEffectiveDepositPayload, -) -from app.modules.dto import OctantRewardsDTO, PendingSnapshotDTO -from app.modules.octant_rewards.core import calculate_rewards -from app.modules.snapshots.pending.core import calculate_user_budgets -from app.modules.staking.proceeds.core import estimate_staking_proceeds -from app.constants import ( - ZERO_ADDRESS, -) -from app.engine.user.effective_deposit.weighted_average.default import ( - DefaultWeightedAverageEffectiveDeposit, -) -from v2.deposits.repositories import DepositEventsRepository - - -def calculate_effective_deposits( - start_sec: int, - end_sec: int, - events: Dict[str, list[DepositEvent]], -) -> tuple[list[UserDeposit], int]: - # TODO: We can do this better and nicer - effective_deposit_calculator = DefaultWeightedAverageEffectiveDeposit() - payload = UserEffectiveDepositPayload( - epoch_start=start_sec, - epoch_end=end_sec, - lock_events_by_addr=events, - ) - - return effective_deposit_calculator.calculate_users_effective_deposits(payload) - - -async def calculate_pending_epoch_snapshot( - deposit_events: DepositEventsRepository, - epoch_number: int, - epoch_start: int, - epoch_end: int, -) -> PendingSnapshotDTO: - # Get octant rewards - # epoch_details = await epochs_subgraph.fetch_epoch_by_number(epoch_number) - # duration_sec = epoch_details.duration - # return estimate_staking_proceeds(duration_sec) - # eth_proceeds = await get_staking_proceeds(session, epoch_number, start_sec, end_sec) - eth_proceeds = estimate_staking_proceeds(epoch_end - epoch_start) - - events = await deposit_events.get_all_users_events( - epoch_number, epoch_start, epoch_end - ) - user_deposits, total_effective_deposit = calculate_effective_deposits( - epoch_start, epoch_end, events - ) - - # total_effective_deposit = 155654569757136462439580980 - rewards_settings = OctantRewardsSettings() - - octant_rewards = calculate_rewards( - rewards_settings, eth_proceeds, total_effective_deposit - ) - - rewards = OctantRewardsDTO( - staking_proceeds=eth_proceeds, - locked_ratio=octant_rewards.locked_ratio, - total_effective_deposit=total_effective_deposit, - total_rewards=octant_rewards.total_rewards, - vanilla_individual_rewards=octant_rewards.vanilla_individual_rewards, - operational_cost=octant_rewards.operational_cost, - ppf=octant_rewards.ppf_value, - community_fund=octant_rewards.community_fund, - ) - - # events = await get_all_user_events( - # session, - # epochs_subgraph, - # sablier, - # epoch_number, - # epoch_start, - # epoch_end - # ) - # user_deposits, total_effective_deposit = calculate_effective_deposits(epoch_start, epoch_end, events) - - user_budget_calculator = UserBudgetWithPPF() - user_budgets = calculate_user_budgets( - user_budget_calculator, rewards, user_deposits - ) - - return PendingSnapshotDTO( - rewards=rewards, user_deposits=user_deposits, user_budgets=user_budgets - ) - - -def simulate_user_events( - end_sec: int, lock_duration: int, remaining_sec: int, glm_amount: int -) -> list[DepositEvent]: - user_events = [ - DepositEvent( - user=ZERO_ADDRESS, - type=EventType.LOCK, - timestamp=end_sec - remaining_sec, - amount=glm_amount, - deposit_before=0, - ) - ] - if lock_duration < remaining_sec: - user_events.append( - DepositEvent( - user=ZERO_ADDRESS, - type=EventType.UNLOCK, - timestamp=end_sec - remaining_sec + lock_duration, - amount=glm_amount, - deposit_before=glm_amount, - ) - ) - return user_events diff --git a/backend/v2/sablier/dependencies.py b/backend/v2/sablier/dependencies.py index b58600f539..2d59a3137d 100644 --- a/backend/v2/sablier/dependencies.py +++ b/backend/v2/sablier/dependencies.py @@ -28,7 +28,7 @@ def get_sablier_subgraph(chain_settings: GetChainSettings) -> SablierSubgraph: Based on the chain type (mainnet or sepolia), return the appropriate SablierSubgraph. """ - sablier_settings = SablierSubgraphSettings() + sablier_settings = SablierSubgraphSettings() # type: ignore[call-arg] if chain_settings.chain_id == ChainTypes.MAINNET: return SablierSubgraph( diff --git a/backend/v2/sablier/subgraphs.py b/backend/v2/sablier/subgraphs.py index c2b0785044..6ec18b4a62 100644 --- a/backend/v2/sablier/subgraphs.py +++ b/backend/v2/sablier/subgraphs.py @@ -114,9 +114,9 @@ async def get_all_streams_history(self) -> list[SablierStream]: "tokenAddress": self.token_address, } + streams = await self._fetch_streams(query, variables) # Cache into file to avoid rate limiting if not os.path.exists("sablier_streams.json"): - streams = await self._fetch_streams(query, variables) with open("sablier_streams.json", "w") as f: json.dump(streams, f) else: From 8ab667143d9d23cd4221efc0a433a905c7e15b9c Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 01:54:58 +0100 Subject: [PATCH 06/14] fix --- backend/app/settings.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/app/settings.py b/backend/app/settings.py index c1b5f1656f..eb2fe7bc99 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -117,10 +117,7 @@ class DevConfig(Config): CHAIN_ID = int(os.getenv("CHAIN_ID", 1337)) # Put the db file in project root DB_PATH = os.path.join(Config.PROJECT_ROOT, DB_NAME) - # SQLALCHEMY_DATABASE_URI = f"sqlite:///{DB_PATH}" - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://postgres:mysecretpassword@localhost:5433/postgres" - ) + SQLALCHEMY_DATABASE_URI = f"sqlite:///{DB_PATH}" SUBGRAPH_RETRY_TIMEOUT_SEC = 2 X_REAL_IP_REQUIRED = parse_bool(os.getenv("X_REAL_IP_REQUIRED", "false")) CACHE_TYPE = "SimpleCache" From 7aa499f00f5e687c29c6023e7459da865187688f Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 01:56:35 +0100 Subject: [PATCH 07/14] fix --- backend/app/context/epoch/block_range.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/app/context/epoch/block_range.py b/backend/app/context/epoch/block_range.py index 95350c60c3..44806c88af 100644 --- a/backend/app/context/epoch/block_range.py +++ b/backend/app/context/epoch/block_range.py @@ -15,6 +15,4 @@ def get_blocks_range( start_block = get_block_num_from_ts(start_sec) if start_sec <= now_sec else None end_block = get_block_num_from_ts(end_sec) if end_sec <= now_sec else None - start_block = None - end_block = None return start_block, end_block From 7054fb3484f255c5ca00435bfb670966618c884e Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 02:12:04 +0100 Subject: [PATCH 08/14] fix --- backend/tests/v2/project_rewards/conftest.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 backend/tests/v2/project_rewards/conftest.py diff --git a/backend/tests/v2/project_rewards/conftest.py b/backend/tests/v2/project_rewards/conftest.py new file mode 100644 index 0000000000..496fae94e6 --- /dev/null +++ b/backend/tests/v2/project_rewards/conftest.py @@ -0,0 +1,2 @@ +from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory # noqa: F401 +from tests.v2.fake_subgraphs.conftest import fake_epochs_subgraph_factory # noqa: F401 \ No newline at end of file From 9dee0232c05f409f26b453f89733cdc6318cb90d Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 02:37:15 +0100 Subject: [PATCH 09/14] fix --- backend/tests/v2/project_rewards/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/v2/project_rewards/conftest.py b/backend/tests/v2/project_rewards/conftest.py index 496fae94e6..651063bb62 100644 --- a/backend/tests/v2/project_rewards/conftest.py +++ b/backend/tests/v2/project_rewards/conftest.py @@ -1,2 +1,2 @@ from tests.v2.fake_contracts.conftest import fake_epochs_contract_factory # noqa: F401 -from tests.v2.fake_subgraphs.conftest import fake_epochs_subgraph_factory # noqa: F401 \ No newline at end of file +from tests.v2.fake_subgraphs.conftest import fake_epochs_subgraph_factory # noqa: F401 From 671592fac7ba7c7d567df9c6e520f0b5010822a3 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Tue, 28 Jan 2025 09:21:04 +0100 Subject: [PATCH 10/14] adds missing fixture --- backend/tests/v2/project_rewards/test_estimated_budged.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/v2/project_rewards/test_estimated_budged.py b/backend/tests/v2/project_rewards/test_estimated_budged.py index 5fc6544b24..04af41db6e 100644 --- a/backend/tests/v2/project_rewards/test_estimated_budged.py +++ b/backend/tests/v2/project_rewards/test_estimated_budged.py @@ -161,6 +161,7 @@ async def test_estimates_budget_for_multiple_epochs( @pytest.mark.asyncio async def test_validates_request_parameters( fast_client: AsyncClient, + fast_session: AsyncSession, ): """Should validate request parameters""" async with fast_client as client: From 3d966f7d31b43ecc7816478e769eaf253dbae327 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Wed, 29 Jan 2025 10:04:08 +0100 Subject: [PATCH 11/14] Adds allocation simulation --- backend/v2/allocations/router.py | 39 +++++++++--- backend/v2/allocations/schemas.py | 10 ++-- backend/v2/allocations/services.py | 95 +++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/backend/v2/allocations/router.py b/backend/v2/allocations/router.py index 58480c6f27..af07955cb5 100644 --- a/backend/v2/allocations/router.py +++ b/backend/v2/allocations/router.py @@ -1,5 +1,10 @@ from typing import Annotated from fastapi import APIRouter, Query +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.projects.dependencies import GetProjectsContracts +from v2.uniqueness_quotients.dependencies import GetUQScoreGetter +from v2.allocations.services import simulate_allocation +from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber from v2.allocations.repositories import ( get_all_allocations_for_epoch, get_allocations_by_user, @@ -15,6 +20,8 @@ EpochAllocationsResponseV1, EpochDonorsResponseV1, ProjectAllocationV1, + SimulateAllocationPayloadV1, + SimulateAllocationResponseV1, UserAllocationNonceV1, UserAllocationRequest, UserAllocationRequestV1, @@ -83,15 +90,29 @@ async def get_all_allocations_for_epoch_v1( return EpochAllocationsResponseV1(allocations=donations) -# @api.post("/leverage/{user_address}") -# async def simulate_allocation_v1( -# user_address: Address, payload: SimulateAllocationPayloadV1 -# ) -> None: -# """ -# Simulates an allocation and get the expected leverage, threshold and matched rewards. -# """ -# # TODO: implement -# pass +@api.post("/leverage/{user_address}") +async def simulate_allocation_v1( + session: GetSession, + projects_contracts: GetProjectsContracts, + matched_rewards_estimator: GetMatchedRewardsEstimator, + uq_score_getter: GetUQScoreGetter, + pending_epoch_number: GetOpenAllocationWindowEpochNumber, + # Request Parameters + user_address: Address, + payload: SimulateAllocationPayloadV1, +) -> SimulateAllocationResponseV1: + """ + Simulates an allocation and get the expected leverage, threshold and matched rewards. + """ + return await simulate_allocation( + session, + projects_contracts, + matched_rewards_estimator, + uq_score_getter, + pending_epoch_number, + user_address, + payload.allocations, + ) @api.get("/project/{project_address}/epoch/{epoch_number}") diff --git a/backend/v2/allocations/schemas.py b/backend/v2/allocations/schemas.py index 633059b196..912a7b84c7 100644 --- a/backend/v2/allocations/schemas.py +++ b/backend/v2/allocations/schemas.py @@ -66,16 +66,16 @@ class UserAllocationsResponseV1(OctantModel): is_manually_edited: bool | None -class SimulateAllocationPayloadV1(OctantModel): - allocations: list[AllocationRequestV1] - - class ProjectMatchedRewardsV1(OctantModel): address: Address value: BigInteger +class SimulateAllocationPayloadV1(OctantModel): + allocations: list[AllocationRequestV1] + + class SimulateAllocationResponseV1(OctantModel): leverage: Decimal - threshold: int + threshold: BigInteger | None matched: list[ProjectMatchedRewardsV1] diff --git a/backend/v2/allocations/services.py b/backend/v2/allocations/services.py index 46e1c8b06d..f5117fe1bb 100644 --- a/backend/v2/allocations/services.py +++ b/backend/v2/allocations/services.py @@ -2,15 +2,27 @@ from app import exceptions from sqlalchemy.ext.asyncio import AsyncSession +from v2.core.types import Address from v2.allocations.repositories import ( get_allocations_with_user_uqs, soft_delete_user_allocations_by_epoch, store_allocation_request, ) -from v2.allocations.schemas import AllocationWithUserUQScore, UserAllocationRequest +from v2.allocations.schemas import ( + AllocationRequestV1, + AllocationWithUserUQScore, + ProjectMatchedRewardsV1, + SimulateAllocationResponseV1, + UserAllocationRequest, +) from v2.allocations.validators import SignatureVerifier from v2.matched_rewards.services import MatchedRewardsEstimator -from v2.project_rewards.capped_quadratic import cqf_simulate_leverage +from v2.project_rewards.capped_quadratic import ( + MR_FUNDING_CAP_PERCENT, + capped_quadratic_funding, + cqf_calculate_individual_leverage, + cqf_simulate_leverage, +) from v2.projects.contracts import ProjectsContracts from v2.uniqueness_quotients.dependencies import UQScoreGetter from v2.users.repositories import get_user_by_address @@ -112,6 +124,85 @@ async def allocate( return request.user_address +async def simulate_allocation( + # Component dependencies + session: AsyncSession, + projects_contracts: ProjectsContracts, + matched_rewards_estimator: MatchedRewardsEstimator, + uq_score_getter: UQScoreGetter, + # Arguments + epoch_number: int, + user_address: Address, + new_allocations: list[AllocationRequestV1], +) -> SimulateAllocationResponseV1: + """ + Simulate the allocation of the user and calculate the leverage and matched project rewards. + This just "ignores" the user's current allocations and calculates the leverage as if the user + had made the allocation. + """ + + # Get or calculate UQ score of the user + user_uq_score = await uq_score_getter.get_or_calculate( + epoch_number=epoch_number, + user_address=user_address, + ) + new_allocations_with_uq = [ + AllocationWithUserUQScore( + project_address=a.project_address, + amount=a.amount, + user_address=user_address, + user_uq_score=user_uq_score, + ) + for a in new_allocations + ] + + # Calculate leverage and matched project rewards + all_projects = await projects_contracts.get_project_addresses(epoch_number) + existing_allocations = await get_allocations_with_user_uqs(session, epoch_number) + matched_rewards = await matched_rewards_estimator.get() + + allocations_without_user = [ + a for a in existing_allocations if a.user_address != user_address + ] + + # Calculate capped quadratic funding before and after the user's allocation + # Leverage is more or less a difference between the two (before - after) + before_allocation = capped_quadratic_funding( + allocations_without_user, + matched_rewards, + all_projects, + MR_FUNDING_CAP_PERCENT, + ) + after_allocation = capped_quadratic_funding( + allocations_without_user + new_allocations_with_uq, + matched_rewards, + all_projects, + MR_FUNDING_CAP_PERCENT, + ) + + leverage = cqf_calculate_individual_leverage( + new_allocations_amount=sum(a.amount for a in new_allocations_with_uq), + project_addresses=[a.project_address for a in new_allocations_with_uq], + before_allocation=before_allocation, + after_allocation=after_allocation, + ) + + return SimulateAllocationResponseV1( + leverage=leverage, + threshold=None, + matched=sorted( + [ + ProjectMatchedRewardsV1( + address=p.address, + value=p.matched, + ) + for p in after_allocation.project_fundings.values() + ], + key=lambda x: x.address, + ), + ) + + async def simulate_leverage( # Component dependencies session: AsyncSession, From 8ed406dfd052949c8f56e154ce331bad6eb5a871 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Wed, 29 Jan 2025 10:04:58 +0100 Subject: [PATCH 12/14] fix: pr comments --- backend/v2/project_rewards/capped_quadratic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/v2/project_rewards/capped_quadratic.py b/backend/v2/project_rewards/capped_quadratic.py index 996cc6986b..1c3b27e9e1 100644 --- a/backend/v2/project_rewards/capped_quadratic.py +++ b/backend/v2/project_rewards/capped_quadratic.py @@ -10,8 +10,8 @@ class CappedQuadraticFunding(NamedTuple): project_fundings: dict[Address, ProjectFundingSummaryV1] - allocations_total_for_all_projects: Decimal # Sum of all allocation amounts for all projects - matched_total_for_all_projects: Decimal # Sum of all matched rewards for all projects + allocations_total_for_all_projects: Decimal + matched_total_for_all_projects: Decimal MR_FUNDING_CAP_PERCENT = Decimal("0.2") From b850e2fbf695a1ed091afaa8f3fbd6857e9ec457 Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Wed, 29 Jan 2025 12:16:55 +0100 Subject: [PATCH 13/14] fix --- backend/v2/sablier/subgraphs.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/backend/v2/sablier/subgraphs.py b/backend/v2/sablier/subgraphs.py index 6ec18b4a62..9bde21000c 100644 --- a/backend/v2/sablier/subgraphs.py +++ b/backend/v2/sablier/subgraphs.py @@ -55,15 +55,12 @@ async def _fetch_streams(self, query: str, variables: dict) -> list[SablierStrea while has_more: variables.update({"limit": limit, "skip": skip}) - # logger.debug(f"[Sablier Subgraph] Querying streams with skip: {skip}") result = await self.gql_client.execute_async( gql(query), variable_values=variables ) streams = result.get("streams", []) - # app.logger.debug(f"[Sablier Subgraph] Received {len(streams)} streams.") - for stream in streams: actions = stream.get("actions", []) final_intact_amount = stream.get("intactAmount", 0) @@ -114,12 +111,4 @@ async def get_all_streams_history(self) -> list[SablierStream]: "tokenAddress": self.token_address, } - streams = await self._fetch_streams(query, variables) - # Cache into file to avoid rate limiting - if not os.path.exists("sablier_streams.json"): - with open("sablier_streams.json", "w") as f: - json.dump(streams, f) - else: - with open("sablier_streams.json", "r") as f: - streams = json.load(f) - return streams + return await self._fetch_streams(query, variables) From 0e13fc9788996ee420af9d5f46e4224975a874be Mon Sep 17 00:00:00 2001 From: adam-gf <adam@golem.foundation> Date: Wed, 29 Jan 2025 12:17:38 +0100 Subject: [PATCH 14/14] fix --- backend/v2/sablier/subgraphs.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/v2/sablier/subgraphs.py b/backend/v2/sablier/subgraphs.py index 9bde21000c..c6fce77843 100644 --- a/backend/v2/sablier/subgraphs.py +++ b/backend/v2/sablier/subgraphs.py @@ -1,6 +1,4 @@ import logging -import os -from flask import json from gql import Client, gql from gql.transport.aiohttp import AIOHTTPTransport from gql.client import log as requests_logger