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/tests/v2/factories/__init__.py b/backend/tests/v2/factories/__init__.py index 561003f34f..0ef06a28a1 100644 --- a/backend/tests/v2/factories/__init__.py +++ b/backend/tests/v2/factories/__init__.py @@ -4,6 +4,10 @@ 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 tests.v2.factories.patrons import PatronModeEventFactorySet from dataclasses import dataclass @@ -23,6 +27,10 @@ class FactoriesAggregator: allocation_requests: AllocationRequestFactorySet allocations: AllocationFactorySet projects_details: ProjectsDetailsFactorySet + budgets: BudgetFactorySet + pending_snapshots: PendingEpochSnapshotFactorySet + finalized_snapshots: FinalizedEpochSnapshotFactorySet + patrons: PatronModeEventFactorySet def __init__(self, fast_session: AsyncSession): """ @@ -32,3 +40,7 @@ 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) + self.patrons = PatronModeEventFactorySet(fast_session) diff --git a/backend/tests/v2/factories/budgets.py b/backend/tests/v2/factories/budgets.py new file mode 100644 index 0000000000..dbb769ca75 --- /dev/null +++ b/backend/tests/v2/factories/budgets.py @@ -0,0 +1,55 @@ +import random +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory +from factory import LazyAttribute + +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 diff --git a/backend/tests/v2/factories/finalized_snapshots.py b/backend/tests/v2/factories/finalized_snapshots.py new file mode 100644 index 0000000000..0a2ee9713d --- /dev/null +++ b/backend/tests/v2/factories/finalized_snapshots.py @@ -0,0 +1,67 @@ +import random +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory +from factory import LazyAttribute + +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 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 new file mode 100644 index 0000000000..5baa787ddd --- /dev/null +++ b/backend/tests/v2/factories/pending_snapshots.py @@ -0,0 +1,86 @@ +import random +from async_factory_boy.factory.sqlalchemy import AsyncSQLAlchemyFactory +from factory import LazyAttribute + +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 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/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/conftest.py b/backend/tests/v2/project_rewards/conftest.py new file mode 100644 index 0000000000..651063bb62 --- /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 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..04af41db6e --- /dev/null +++ b/backend/tests/v2/project_rewards/test_estimated_budged.py @@ -0,0 +1,187 @@ +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +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 FakeEpochsContractCallable +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 + await factories.pending_snapshots.create(epoch=finalized_epoch) + + # finalize epoch 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) + + 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 + 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) + + 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() + 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, + fast_session: AsyncSession, +): + """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 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..a76122f602 --- /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() + 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..9c73c6f9c1 --- /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) + 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 + } 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..c22824f20b --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_rewards_leverage.py @@ -0,0 +1,193 @@ +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.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 get_matched_rewards_estimator + + +@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 + 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 + 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 + await factories.pending_snapshots.create( + epoch=epoch_number, + ) + + # Create 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} 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 new file mode 100644 index 0000000000..eb9febe031 --- /dev/null +++ b/backend/tests/v2/project_rewards/test_get_upcoming_budget.py @@ -0,0 +1,114 @@ +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 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 + + +@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 + 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 + + 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/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 a5b15dc9ef..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_quadriatic 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, 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/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/core/dependencies.py b/backend/v2/core/dependencies.py index 4c238dcf0f..508138d5a0 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,9 @@ from web3 import AsyncHTTPProvider, AsyncWeb3 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): model_config = SettingsConfigDict(env_file=".env", extra="ignore", frozen=True) @@ -107,6 +111,12 @@ class ChainSettings(OctantSettings): description="The chain id to use for the signature verification.", ) + is_mainnet: bool = Field( + default_factory=lambda: compare_blockchain_types( + Field(validation_alias="chain_id"), ChainTypes.MAINNET + ) + ) + def get_chain_settings() -> ChainSettings: return ChainSettings() @@ -127,7 +137,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 cbe9a36500..e1a350fcf6 100644 --- a/backend/v2/deposits/dependencies.py +++ b/backend/v2/deposits/dependencies.py @@ -1,7 +1,14 @@ from typing import Annotated from fastapi import Depends -from v2.core.dependencies import OctantSettings, Web3 +from app.constants import ( + SABLIER_UNLOCK_GRACE_PERIOD_24_HRS, + TEST_SABLIER_UNLOCK_GRACE_PERIOD_15_MIN, +) +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 from v2.deposits.contracts import DEPOSITS_ABI, DepositsContracts @@ -17,3 +24,30 @@ 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, + chain_settings: GetChainSettings, +) -> 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 DepositEventsStore( + session, + epochs_subgraph, + sublier_subgraph, + sablier_unlock_grace_period, + ) + + +# Annotated dependencies +GetDepositsContracts = Annotated[DepositsContracts, Depends(get_deposits_contracts)] +GetDepositEventsRepository = Annotated[ + DepositEventsStore, Depends(get_deposit_events_repository) +] diff --git a/backend/v2/deposits/repositories.py b/backend/v2/deposits/repositories.py new file mode 100644 index 0000000000..b6d9083e64 --- /dev/null +++ b/backend/v2/deposits/repositories.py @@ -0,0 +1,22 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.infrastructure.database.models import Deposit +from v2.core.types import Address +from v2.core.transformers import transform_to_checksum_address + + +async def get_all_deposit_events_for_epoch( + session: AsyncSession, + epoch_number: int, +) -> dict[Address, Deposit]: + results = await session.scalars( + select(Deposit) + .options(selectinload(Deposit.user)) + .where(Deposit.epoch == epoch_number) + ) + + return { + transform_to_checksum_address(result.user.address): result for result in results + } 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/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/contracts.py b/backend/v2/epochs/contracts.py index 8ef263bc96..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 @@ -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 67f391f599..f7ff2cc88e 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 epochs_contracts.get_current_epoch() + 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..621389af55 100644 --- a/backend/v2/epochs/subgraphs.py +++ b/backend/v2/epochs/subgraphs.py @@ -2,8 +2,12 @@ from dataclasses import dataclass from typing import Callable, Sequence, Type, Union +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 from gql import Client, gql @@ -42,6 +46,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 +78,72 @@ def __init__( self.gql_client.execute_async ) - async def get_epoch_by_number(self, epoch_number: int) -> EpochDetails: - """Get EpochDetails from the subgraph for a given epoch number.""" + 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"] - logging.debug( - f"[Subgraph] Getting epoch properties for epoch number: {epoch_number}" + 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"] - # Prepare query and variables + async def fetch_epoch_by_number(self, epoch_number: int) -> EpochSubgraphItem: + """Get EpochDetails from the subgraph for a given epoch number.""" query = gql( """\ query GetEpoch($epochNo: Int!) { @@ -85,31 +155,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/glms/contracts.py b/backend/v2/glms/contracts.py index 4a167009be..8b5d45c8f8 100644 --- a/backend/v2/glms/contracts.py +++ b/backend/v2/glms/contracts.py @@ -29,7 +29,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) + + 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..084efb1be4 100644 --- a/backend/v2/glms/dependencies.py +++ b/backend/v2/glms/dependencies.py @@ -1,6 +1,8 @@ +from __future__ import annotations from typing import Annotated from fastapi import Depends +from v2.deposits.dependencies import GetDepositsContracts from v2.core.dependencies import OctantSettings, Web3 from v2.glms.contracts import ERC20_ABI, GLMContracts @@ -17,3 +19,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)] diff --git a/backend/v2/matched_rewards/dependencies.py b/backend/v2/matched_rewards/dependencies.py index cdafa0ed3f..3abf80a79b 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,6 +47,30 @@ 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), diff --git a/backend/v2/project_rewards/capped_quadriatic.py b/backend/v2/project_rewards/capped_quadratic.py similarity index 88% rename from backend/v2/project_rewards/capped_quadriatic.py rename to backend/v2/project_rewards/capped_quadratic.py index 8fa3197c94..1c3b27e9e1 100644 --- a/backend/v2/project_rewards/capped_quadriatic.py +++ b/backend/v2/project_rewards/capped_quadratic.py @@ -5,24 +5,24 @@ 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] - amounts_total: Decimal # Sum of all allocation amounts for all projects - matched_total: Decimal # Sum of all matched rewards for all projects +class CappedQuadraticFunding(NamedTuple): + project_fundings: dict[Address, ProjectFundingSummaryV1] + allocations_total_for_all_projects: Decimal + matched_total_for_all_projects: Decimal 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 @@ -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]), @@ -104,10 +104,10 @@ 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, + allocations_total_for_all_projects=amounts_total, + matched_total_for_all_projects=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/repositories.py b/backend/v2/project_rewards/repositories.py new file mode 100644 index 0000000000..9836d6fecc --- /dev/null +++ b/backend/v2/project_rewards/repositories.py @@ -0,0 +1,37 @@ +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( + 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..fd6838e835 100644 --- a/backend/v2/project_rewards/router.py +++ b/backend/v2/project_rewards/router.py @@ -1,22 +1,492 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Response, status +from app.context.epoch_state import EpochState +from app.exceptions import ( + InvalidEpoch, + MissingSnapshot, + NotImplementedForGivenEpochState, +) +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.deposits.dependencies import GetDepositEventsRepository +from v2.glms.dependencies import GetGLMBalanceOfDeposits +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 GetCurrentTimestamp, GetSession +from v2.project_rewards.services import ( + calculate_effective_deposits, + 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 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, + response: Response, +) -> UserBudgetResponseV1 | None: + """ + 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, + user_address, + epoch_number, + ) + + if budget is None: + response.status_code = status.HTTP_204_NO_CONTENT + return None + + return UserBudgetResponseV1(budget=budget) + + +@api.get("/budget/{user_address}/upcoming") +async def get_user_budget_for_upcoming_epoch_v1( + # 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 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 + """ + + # 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 = current_timestamp + epoch_start = epoch_details.fromTs + + # 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 + ) + 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 + ) + user_budget = next( + ( + budget.budget + for budget in user_budgets + if budget.user_address == user_address + ), + 0, # Default value if user not found + ) + + return UpcomingUserBudgetResponseV1(upcoming_budget=user_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( + session: GetSession, + epoch_contracts: GetEpochsContracts, + # Parameters + glm_balance: GetGLMBalanceOfDeposits, + request: EstimatedBudgetByEpochRequestV1, +) -> UserBudgetWithMatchedFundingResponseV1: + """ + 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() + 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) + + # Simulate budget + user_effective_deposit = simulate_user_effective_deposits( + epoch_start, + epoch_end, + epoch_remaining, + epoch_duration, + request.glm_amount, + ) + user_budget = calculate_user_budget( + user_effective_deposit, + future_rewards, + ) + + epochs_budget = request.number_of_epochs * user_budget + + # Matching fund is calculated based on the last epoch's leverage + epoch_number = await epoch_contracts.get_finalized_epoch() + + # 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 * leverage), + ) + + +@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, + current_timestamp: GetCurrentTimestamp, + request: EstimatedBudgetByDaysRequestV1, +) -> UserBudgetResponseV1: + """ + 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. + """ + + 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_start = current_epoch_details.fromTs + current_epoch_duration = current_epoch_details.duration + 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, + ) + _, 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 + ) + + # 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, + ) + + # 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] + 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 = calculate_octant_rewards( + future_eth_proceeds, future_total_effective_deposit + ) + + # 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 + ) + + # 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, + ) + budget += full_epochs_num * calculate_user_budget( + user_effective_deposit, + future_rewards, + ) + + if remaining_future_epoch_sec > 0: + user_effective_deposit = simulate_user_effective_deposits( + future_epoch_start, + future_epoch_end, + future_epoch_duration, + remaining_future_epoch_sec, + request.glm_amount, + ) + budget += calculate_user_budget( + user_effective_deposit, + future_rewards, + ) + + return UserBudgetResponseV1(budget=budget) + + +@api.get("/leverage/{epoch_number}") +async def get_rewards_leverage_v1( + session: GetSession, + epochs_contracts: GetEpochsContracts, + matched_rewards_estimator: GetMatchedRewardsEstimator, + # Parameters + 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. + For pending and finalizing epochs it returns the leverage based on the estimated matched rewards of pending epoch. + """ + + epoch_state = await get_epoch_state(session, epochs_contracts, epoch_number) + + # 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: + # For finalized epoch matched_rewards is just taken from the finalized snapshot + finalized_snapshot = await get_finalized_epoch_snapshot(session, epoch_number) + if finalized_snapshot is None: + raise MissingSnapshot() + matched_rewards = int(finalized_snapshot.matched_rewards) + + else: + # For pending or finalizing epoch matched_rewards is estimated based on current state of AW + matched_rewards = await matched_rewards_estimator.get() + + allocations_sum = await sum_allocations_by_epoch(session, epoch_number) + + return RewardsLeverageResponseV1( + leverage=matched_rewards / allocations_sum if allocations_sum 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. + """ + + 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") -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. - 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() - 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. + + """ + + # 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) + + 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 list of users who didn't use their rewards in an epoch and the total sum of all unused rewards. + + 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) + donors = await get_donors_for_epoch(session, epoch_number) + epoch_details = await epoch_subgraph.get_epoch_by_number(epoch_number) + patrons = await get_all_patrons_at_timestamp( + session, epoch_details.finalized_timestamp.datetime() + ) + + # 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=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 5c53fce910..49e6cc5ffb 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,93 @@ 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", gt=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" + ) + + @property + def lock_duration_sec(self) -> int: + return self.days * 24 * 60 * 60 + + +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 3ef386d1fd..6ab93bdd6d 100644 --- a/backend/v2/project_rewards/services.py +++ b/backend/v2/project_rewards/services.py @@ -1,16 +1,81 @@ from dataclasses import dataclass +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.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 ( - CappedQuadriaticFunding, - capped_quadriatic_funding, +from v2.project_rewards.capped_quadratic import ( + CappedQuadraticFunding, + capped_quadratic_funding, ) 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 @@ -21,7 +86,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 @@ -34,8 +99,118 @@ 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, ) + + +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, + ) + + +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) + + 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, + ) + + +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/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 116c919007..7dbe08868c 100644 --- a/backend/v2/projects/dependencies.py +++ b/backend/v2/projects/dependencies.py @@ -97,14 +97,21 @@ 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 ) +GetProjectsAllocationThresholdGetter = Annotated[ + ProjectsAllocationThresholdGetter, + Depends(get_projects_allocation_threshold_getter), +] + GetProjectsContracts = Annotated[ ProjectsContracts, Depends(get_projects_contracts), 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..2d59a3137d --- /dev/null +++ b/backend/v2/sablier/dependencies.py @@ -0,0 +1,50 @@ +from functools import lru_cache +from typing import Annotated + +from fastapi import Depends +from pydantic import Field +from app.shared.blockchain_types import ChainTypes +from v2.sablier.subgraphs import SablierSubgraph +from v2.core.dependencies import GetChainSettings, OctantSettings + + +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() # type: ignore[call-arg] + + 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/sablier/subgraphs.py b/backend/v2/sablier/subgraphs.py new file mode 100644 index 0000000000..c6fce77843 --- /dev/null +++ b/backend/v2/sablier/subgraphs.py @@ -0,0 +1,112 @@ +import logging +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 + +requests_logger.setLevel(logging.WARNING) + + +class SablierSubgraph: + def __init__( + self, + url: str, + sender: str, + 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, + ), + 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}) + + result = await self.gql_client.execute_async( + gql(query), variable_values=variables + ) + + streams = result.get("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/user_patron_mode/repositories.py b/backend/v2/user_patron_mode/repositories.py index 903660a5ef..422cc06832 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,24 @@ 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) + .order_by(User.address) + ) + 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,