diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 7d01ad42e..e57ad3d07 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -7,7 +7,6 @@ db, migrate, cors, - socketio, cache, init_web3, api, diff --git a/backend/startup.py b/backend/startup.py index c4ccecc60..dc3596d3e 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -52,11 +52,12 @@ def teardown_session(*args, **kwargs): db.session.remove() -from v2.main import fastapi_app +from v2.main import fastapi_app # noqa # Mount Flask app under a sub-path fastapi_app.mount("/flask", WSGIMiddleware(flask_app)) + # Middleware to check if the path exists in FastAPI class PathCheckMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): @@ -80,4 +81,4 @@ async def dispatch(self, request: Request, call_next): if __name__ == "__main__": import uvicorn - uvicorn.run(fastapi_app, host="0.0.0.0", port=5000) \ No newline at end of file + uvicorn.run(fastapi_app, host="0.0.0.0", port=5000) diff --git a/backend/v2/allocations/dependencies.py b/backend/v2/allocations/dependencies.py new file mode 100644 index 000000000..9a58f3a92 --- /dev/null +++ b/backend/v2/allocations/dependencies.py @@ -0,0 +1,55 @@ +from typing import Annotated + +from fastapi import Depends +from pydantic import Field +from pydantic_settings import BaseSettings +from v2.projects.services import EstimatedProjectMatchedRewards +from v2.epochs.dependencies import get_epochs_subgraph +from v2.epochs.subgraphs import EpochsSubgraph +from v2.projects.contracts import ProjectsContracts +from v2.projects.depdendencies import ( + get_estimated_project_matched_rewards, + get_projects_contracts, +) +from v2.uniqueness_quotients.dependencies import get_uq_score_getter +from v2.uniqueness_quotients.services import UQScoreGetter +from v2.core.dependencies import AsyncDbSession + +from .services import Allocations +from .validators import SignatureVerifier + + +class SignatureVerifierSettings(BaseSettings): + chain_id: int = Field( + default=11155111, + description="The chain id to use for the signature verification.", + ) + + +def get_signature_verifier( + session: AsyncDbSession, + epochs_subgraph: Annotated[EpochsSubgraph, Depends(get_epochs_subgraph)], + projects_contracts: Annotated[ProjectsContracts, Depends(get_projects_contracts)], + settings: Annotated[SignatureVerifierSettings, Depends(SignatureVerifierSettings)], +) -> SignatureVerifier: + return SignatureVerifier( + session, epochs_subgraph, projects_contracts, settings.chain_id + ) + + +def get_allocations( + session: AsyncDbSession, + signature_verifier: SignatureVerifier, + uq_score_getter: Annotated[UQScoreGetter, Depends(get_uq_score_getter)], + projects: Annotated[ProjectsContracts, Depends(get_projects_contracts)], + estimated_project_matched_rewards: Annotated[ + EstimatedProjectMatchedRewards, Depends(get_estimated_project_matched_rewards) + ], +) -> Allocations: + return Allocations( + session, + signature_verifier, + uq_score_getter, + projects, + estimated_project_matched_rewards, + ) diff --git a/backend/v2/allocations/repositories.py b/backend/v2/allocations/repositories.py index 3dafd1311..76e837424 100644 --- a/backend/v2/allocations/repositories.py +++ b/backend/v2/allocations/repositories.py @@ -11,7 +11,7 @@ from sqlalchemy.sql.functions import coalesce from v2.users.repositories import get_user_by_address -from .models import AllocationWithUserUQScore, ProjectDonation, UserAllocationRequest +from .schemas import AllocationWithUserUQScore, ProjectDonation, UserAllocationRequest async def sum_allocations_by_epoch(session: AsyncSession, epoch_number: int) -> int: @@ -150,15 +150,17 @@ async def get_donations_by_project( project_address: str, epoch_number: int, ) -> list[ProjectDonation]: + """Get all donations for a project in a given epoch.""" + result = await session.execute( select(Allocation) + .options(joinedload(Allocation.user)) .filter(Allocation.project_address == project_address) .filter(Allocation.epoch == epoch_number) .filter(Allocation.deleted_at.is_(None)) - .options(joinedload(Allocation.user)) ) - allocations = result.all() + allocations = result.scalars().all() return [ ProjectDonation( diff --git a/backend/v2/allocations/models.py b/backend/v2/allocations/schemas.py similarity index 100% rename from backend/v2/allocations/models.py rename to backend/v2/allocations/schemas.py diff --git a/backend/v2/allocations/services.py b/backend/v2/allocations/services.py index 02cd2b1d1..686819c66 100644 --- a/backend/v2/allocations/services.py +++ b/backend/v2/allocations/services.py @@ -1,74 +1,74 @@ -from decimal import Decimal +from dataclasses import dataclass from app import exceptions -from app.modules.common.crypto.signature import EncodingStandardFor, encode_for_signing from sqlalchemy.ext.asyncio import AsyncSession -from v2.crypto.signatures import verify_signed_message -from v2.epochs.subgraphs import EpochsSubgraph +from v2.uniqueness_quotients.dependencies import UQScoreGetter from v2.project_rewards.capped_quadriatic import ( - capped_quadriatic_funding, - cqf_calculate_individual_leverage, + cqf_simulate_leverage, ) from v2.projects.contracts import ProjectsContracts -from v2.projects.services import get_estimated_project_matched_rewards_pending -from v2.uniqueness_quotients.services import get_or_calculate_uq_score -from v2.user_patron_mode.repositories import ( - get_budget_by_user_address_and_epoch, - user_is_patron_with_budget, +from v2.projects.services import ( + EstimatedProjectMatchedRewards, ) from v2.users.repositories import get_user_by_address -from web3 import AsyncWeb3 -from .models import AllocationWithUserUQScore, UserAllocationRequest +from .validators import SignatureVerifier +from .schemas import AllocationWithUserUQScore, UserAllocationRequest from .repositories import ( get_allocations_with_user_uqs, - get_last_allocation_request_nonce, soft_delete_user_allocations_by_epoch, store_allocation_request, ) +@dataclass +class Allocations: + session: AsyncSession + signature_verifier: SignatureVerifier + uq_score_getter: UQScoreGetter + projects: ProjectsContracts + estimated_project_matched_rewards: EstimatedProjectMatchedRewards + + async def make( + self, + epoch_number: int, + request: UserAllocationRequest, + ) -> str: + """ + Make an allocation for the user. + """ + return await allocate( + session=self.session, + signature_verifier=self.signature_verifier, + uq_score_getter=self.uq_score_getter, + projects=self.projects, + estimated_project_matched_rewards=self.estimated_project_matched_rewards, + epoch_number=epoch_number, + request=request, + ) + + async def allocate( # Component dependencies session: AsyncSession, - projects_contracts: ProjectsContracts, - epochs_subgraph: EpochsSubgraph, + signature_verifier: SignatureVerifier, + uq_score_getter: UQScoreGetter, + projects: ProjectsContracts, + estimated_project_matched_rewards: EstimatedProjectMatchedRewards, # Arguments epoch_number: int, request: UserAllocationRequest, - # Settings - uq_score_threshold: float = 21.0, - low_uq_score: Decimal = Decimal("0.2"), - max_uq_score: Decimal = Decimal("1.0"), - chain_id: int = 11155111, ) -> str: - await verify_logic( - session=session, - epoch_subgraph=epochs_subgraph, - projects_contracts=projects_contracts, + # Verify the signature + await signature_verifier.verify( epoch_number=epoch_number, - payload=request, - ) - await verify_signature( - w3=projects_contracts.w3, - chain_id=chain_id, - user_address=request.user_address, - payload=request, + request=request, ) - # Get user - # ? Do we need to get the user here ? - # user = await get_user_by_address(session, request.user_address) - # Get or calculate UQ score of the user - # TODO: k=v arguments - user_uq_score = await get_or_calculate_uq_score( - session=session, - user_address=request.user_address, + user_uq_score = await uq_score_getter.get_or_calculate( epoch_number=epoch_number, - uq_score_threshold=uq_score_threshold, - max_uq_score=max_uq_score, - low_uq_score=low_uq_score, + user_address=request.user_address, ) # Calculate leverage by simulating the allocation @@ -81,12 +81,12 @@ async def allocate( ) for a in request.allocations ] - leverage = await calculate_leverage( + + leverage = await simulate_leverage( session=session, - projects=projects_contracts, - epochs_subgraph=epochs_subgraph, + projects=projects, + estimated_project_matched_rewards=estimated_project_matched_rewards, epoch_number=epoch_number, - user_address=request.user_address, new_allocations=new_allocations, ) @@ -117,14 +117,13 @@ async def allocate( return request.user_address -async def calculate_leverage( +async def simulate_leverage( # Component dependencies session: AsyncSession, projects: ProjectsContracts, - epochs_subgraph: EpochsSubgraph, + estimated_project_matched_rewards: EstimatedProjectMatchedRewards, # Arguments epoch_number: int, - user_address: str, new_allocations: list[AllocationWithUserUQScore], ) -> float: """ @@ -133,183 +132,14 @@ async def calculate_leverage( all_projects = await projects.get_project_addresses(epoch_number) - matched_rewards = await get_estimated_project_matched_rewards_pending( - session=session, - epochs_subgraph=epochs_subgraph, - epoch_number=epoch_number, - ) + matched_rewards = await estimated_project_matched_rewards.get(epoch_number) # Get all allocations before user's allocation existing_allocations = await get_allocations_with_user_uqs(session, epoch_number) - # Remove allocations made by this user (as they will be removed in a second) - allocations_without_user = [ - a for a in existing_allocations if a.user_address != user_address - ] - - # Calculate funding without user's allocations - before = capped_quadriatic_funding( - allocations=allocations_without_user, - matched_rewards=matched_rewards, - project_addresses=all_projects, - ) - # Calculate funding with user's allocations - after = capped_quadriatic_funding( - allocations=allocations_without_user + new_allocations, + return cqf_simulate_leverage( + existing_allocations=existing_allocations, + new_allocations=new_allocations, matched_rewards=matched_rewards, project_addresses=all_projects, ) - - # Calculate leverage based on the difference in funding - return cqf_calculate_individual_leverage( - new_allocations_amount=sum(a.amount for a in new_allocations), - project_addresses=[a.project_address for a in new_allocations], - before_allocation_matched=before.matched_by_project, - after_allocation_matched=after.matched_by_project, - ) - - -async def verify_logic( - # Component dependencies - session: AsyncSession, - epoch_subgraph: EpochsSubgraph, - projects_contracts: ProjectsContracts, - # Arguments - epoch_number: int, - payload: UserAllocationRequest, -): - # Check if the epoch is in the decision window - # epoch_details = await epoch_subgraph.get_epoch_by_number(epoch_number) - # if epoch_details.state != "PENDING": - # raise exceptions.NotInDecision - - # Check if the allocations are not empty - if not payload.allocations: - raise exceptions.EmptyAllocations() - - # Check if the nonce is as expected - expected_nonce = await get_next_user_nonce(session, payload.user_address) - if payload.nonce != expected_nonce: - raise exceptions.WrongAllocationsNonce(payload.nonce, expected_nonce) - - # Check if the user is not a patron - epoch_details = await epoch_subgraph.get_epoch_by_number(epoch_number) - is_patron = await user_is_patron_with_budget( - session, - payload.user_address, - epoch_number, - epoch_details.finalized_timestamp.datetime(), - ) - if is_patron: - raise exceptions.NotAllowedInPatronMode(payload.user_address) - - # Check if the user is not a project - all_projects = await projects_contracts.get_project_addresses(epoch_number) - if payload.user_address in all_projects: - raise exceptions.ProjectAllocationToSelf() - - project_addresses = [a.project_address for a in payload.allocations] - - # Check if the projects are valid - invalid_projects = set(project_addresses) - set(all_projects) - if invalid_projects: - raise exceptions.InvalidProjects(invalid_projects) - - # Check if there are no duplicates - duplicates = [p for p in project_addresses if project_addresses.count(p) > 1] - if duplicates: - raise exceptions.DuplicatedProjects(duplicates) - - # Get the user's budget - user_budget = await get_budget_by_user_address_and_epoch( - session, payload.user_address, epoch_number - ) - - if user_budget is None: - raise exceptions.BudgetNotFound(payload.user_address, epoch_number) - - # Check if the allocations are within the budget - if sum(a.amount for a in payload.allocations) > user_budget: - raise exceptions.RewardsBudgetExceeded() - - -async def get_next_user_nonce( - # Component dependencies - session: AsyncSession, - # Arguments - user_address: str, -) -> int: - """ - Get the next expected nonce for the user. - It's a simple increment of the last nonce, or 0 if there is no previous nonce. - """ - # Get the last allocation request of the user - last_allocation_request = await get_last_allocation_request_nonce( - session, user_address - ) - - # Calculate the next nonce - if last_allocation_request is None: - return 0 - - # Increment the last nonce - return last_allocation_request + 1 - - -async def verify_signature( - w3: AsyncWeb3, chain_id: int, user_address: str, payload: UserAllocationRequest -) -> None: - eip712_encoded = build_allocations_eip712_structure(chain_id, payload) - encoded_msg = encode_for_signing(EncodingStandardFor.DATA, eip712_encoded) - - # Verify the signature - is_valid = await verify_signed_message( - w3, user_address, encoded_msg, payload.signature - ) - if not is_valid: - raise exceptions.InvalidSignature(user_address, payload.signature) - - -def build_allocations_eip712_structure(chain_id: int, payload: UserAllocationRequest): - message = {} - message["allocations"] = [ - {"proposalAddress": a.project_address, "amount": a.amount} - for a in payload.allocations - ] - message["nonce"] = payload.nonce # type: ignore - return build_allocations_eip712_data(chain_id, message) - - -def build_allocations_eip712_data(chain_id: int, message: dict) -> dict: - # Convert amount value to int - message["allocations"] = [ - {**allocation, "amount": int(allocation["amount"])} - for allocation in message["allocations"] - ] - - allocation_types = { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - ], - "Allocation": [ - {"name": "proposalAddress", "type": "address"}, - {"name": "amount", "type": "uint256"}, - ], - "AllocationPayload": [ - {"name": "allocations", "type": "Allocation[]"}, - {"name": "nonce", "type": "uint256"}, - ], - } - - return { - "types": allocation_types, - "domain": { - "name": "Octant", - "version": "1.0.0", - "chainId": chain_id, - }, - "primaryType": "AllocationPayload", - "message": message, - } diff --git a/backend/v2/allocations/socket.py b/backend/v2/allocations/socket.py index 7ea9718ee..c95ea2899 100644 --- a/backend/v2/allocations/socket.py +++ b/backend/v2/allocations/socket.py @@ -1,60 +1,103 @@ import logging +from typing import Tuple import socketio -# from app.extensions import socketio, epochs from eth_utils import to_checksum_address -from v2.allocations.repositories import get_donations_by_project -from v2.allocations.services import allocate -from v2.core.dependencies import db_getter -from v2.epochs.dependencies import epochs_getter, epochs_subgraph_getter -from v2.projects.depdendencies import projects_getter +from v2.allocations.dependencies import ( + SignatureVerifierSettings, + get_allocations, + get_signature_verifier, +) +from v2.epochs.contracts import EpochsContracts from v2.projects.services import ( + EstimatedProjectRewards, + ProjectsAllocationThresholdGetter, +) +from v2.uniqueness_quotients.dependencies import UQScoreSettings, get_uq_score_getter +from v2.allocations.repositories import get_donations_by_project +from v2.allocations.services import Allocations +from v2.core.dependencies import ( + DatabaseSettings, + Web3ProviderSettings, + get_db_session, + get_w3, +) +from v2.epochs.dependencies import ( + EpochsSettings, + EpochsSubgraphSettings, + get_epochs_contracts, + get_epochs_subgraph, +) +from v2.projects.depdendencies import ( + EstimatedProjectMatchedRewardsSettings, + ProjectsAllocationThresholdSettings, + ProjectsSettings, + get_estimated_project_matched_rewards, get_estimated_project_rewards, - get_projects_allocation_threshold, + get_projects_contracts, ) +from v2.projects.depdendencies import get_projects_allocation_threshold_getter + +from .schemas import AllocationRequest, UserAllocationRequest -from .models import AllocationRequest, UserAllocationRequest + +from sqlalchemy.ext.asyncio import AsyncSession class AllocateNamespace(socketio.AsyncNamespace): - def __init__(self, namespace: str): - super().__init__(namespace=namespace) + def create_dependencies_on_connect( + self, + session: AsyncSession, + ) -> Tuple[ + ProjectsAllocationThresholdGetter, EstimatedProjectRewards, EpochsContracts + ]: + """ + Create and return all service dependencies. + TODO: how could we cache this one ? + """ + w3 = get_w3(Web3ProviderSettings()) # type: ignore + projects_contracts = get_projects_contracts(w3, ProjectsSettings()) + threshold_getter = get_projects_allocation_threshold_getter( + session, projects_contracts, ProjectsAllocationThresholdSettings() + ) + epochs_contracts = get_epochs_contracts(w3, EpochsSettings()) + epochs_subgraph = get_epochs_subgraph(EpochsSubgraphSettings()) + estimated_matched_rewards = get_estimated_project_matched_rewards( + session, epochs_subgraph, EstimatedProjectMatchedRewardsSettings() + ) + estimated_project_rewards = get_estimated_project_rewards( + session, + projects_contracts, + estimated_matched_rewards, + ) - # self.w3 = w3_getter() - self.epochs_contracts = epochs_getter() - self.epochs_subgraph = epochs_subgraph_getter() - self.projects_contracts = projects_getter() - self.db_session = db_getter() + return (threshold_getter, estimated_project_rewards, epochs_contracts) - async def on_connect(self, sid: str, environ: dict): + async def handle_on_connect( + self, + epochs_contracts: EpochsContracts, + projects_allocation_threshold_getter: ProjectsAllocationThresholdGetter, + estimated_project_rewards: EstimatedProjectRewards, + ): """ Handle client connection """ logging.debug("Client connected") - # We send the data only in PENDING state - pending_epoch_number = await self.epochs_contracts.get_pending_epoch() + pending_epoch_number = await epochs_contracts.get_pending_epoch() if pending_epoch_number is None: return - async with self.db_session() as session: - threshold = await get_projects_allocation_threshold( - session=session, - projects=self.projects_contracts, - epoch_number=pending_epoch_number, - ) - - await self.emit("threshold", {"threshold": str(threshold)}) - - project_rewards = await get_estimated_project_rewards( - session=session, - projects=self.projects_contracts, - epochs_subgraph=self.epochs_subgraph, - epoch_number=pending_epoch_number, - ) + # Get the allocation threshold and send it to the client + allocation_threshold = await projects_allocation_threshold_getter.get( + epoch_number=pending_epoch_number + ) + await self.emit("threshold", {"threshold": str(allocation_threshold)}) + # Get the estimated project rewards and send them to the client + project_rewards = await estimated_project_rewards.get(pending_epoch_number) rewards = [ { "address": project_address, @@ -66,68 +109,142 @@ async def on_connect(self, sid: str, environ: dict): await self.emit("project_rewards", rewards) + async def on_connect(self, sid: str, environ: dict): + async with get_db_session(DatabaseSettings()) as session: + ( + projects_allocation_threshold_getter, + estimated_project_rewards, + epochs_contracts, + ) = self.create_dependencies_on_connect(session) + + await self.handle_on_connect( + epochs_contracts, + projects_allocation_threshold_getter, + estimated_project_rewards, + ) + async def on_disconnect(self, sid): logging.debug("Client disconnected") - async def on_allocate(self, sid: str, data: dict): + def create_dependencies_on_allocate( + self, + session: AsyncSession, + ) -> Tuple[ + Allocations, + EpochsContracts, + ProjectsAllocationThresholdGetter, + EstimatedProjectRewards, + ]: + """ + Create and return all service dependencies. + """ + + w3 = get_w3(Web3ProviderSettings()) + epochs_contracts = get_epochs_contracts(w3, EpochsSettings()) + projects_contracts = get_projects_contracts(w3, ProjectsSettings()) + epochs_subgraph = get_epochs_subgraph(EpochsSubgraphSettings()) + threshold_getter = get_projects_allocation_threshold_getter( + session, projects_contracts, ProjectsAllocationThresholdSettings() + ) + estimated_matched_rewards = get_estimated_project_matched_rewards( + session, epochs_subgraph, EstimatedProjectMatchedRewardsSettings() + ) + estimated_project_rewards = get_estimated_project_rewards( + session, + projects_contracts, + estimated_matched_rewards, + ) + + signature_verifier = get_signature_verifier( + session, epochs_subgraph, projects_contracts, SignatureVerifierSettings() + ) + + uq_score_getter = get_uq_score_getter(session, UQScoreSettings()) + + allocations = get_allocations( + session, + signature_verifier, + uq_score_getter, + projects_contracts, + estimated_matched_rewards, + ) + + return ( + allocations, + epochs_contracts, + threshold_getter, + estimated_project_rewards, + ) + + async def handle_on_allocate( + self, + session: AsyncSession, + epochs_contracts: EpochsContracts, + allocations: Allocations, + threshold_getter: ProjectsAllocationThresholdGetter, + estimated_project_rewards: EstimatedProjectRewards, + data: dict, + ): """ Handle allocation request """ # We do not handle requests outside of pending epoch state (Allocation Window) - pending_epoch_number = await self.epochs_contracts.get_pending_epoch() + pending_epoch_number = await epochs_contracts.get_pending_epoch() if pending_epoch_number is None: return + pending_epoch_number = 1 request = from_dict(data) - async with self.db_session() as session: - await allocate( - session=session, - projects_contracts=self.projects_contracts, - epochs_subgraph=self.epochs_subgraph, - epoch_number=pending_epoch_number, - request=request, - ) + await allocations.make(pending_epoch_number, request) - threshold = await get_projects_allocation_threshold( - session=session, - projects=self.projects_contracts, - epoch_number=pending_epoch_number, - ) + logging.debug("Allocation request handled") - await self.emit("threshold", {"threshold": str(threshold)}) - project_rewards = await get_estimated_project_rewards( + threshold = await threshold_getter.get(pending_epoch_number) + await self.emit("threshold", {"threshold": str(threshold)}) + + project_rewards = await estimated_project_rewards.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() + ] + + await self.emit("project_rewards", rewards) + + for project_address in project_rewards.amounts_by_project.keys(): + donations = await get_donations_by_project( session=session, - projects=self.projects_contracts, - epochs_subgraph=self.epochs_subgraph, + project_address=project_address, epoch_number=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() - ] - - await self.emit("project_rewards", rewards) + await self.emit( + "project_donors", + {"project": project_address, "donors": donations}, + ) - for project_address in project_rewards.amounts_by_project.keys(): - donations = await get_donations_by_project( - session=session, - project_address=project_address, - epoch_number=pending_epoch_number, - ) + async def on_allocate(self, sid: str, data: dict): + async with get_db_session(DatabaseSettings()) as session: + ( + allocations, + epochs_contracts, + threshold_getter, + estimated_project_rewards, + ) = self.create_dependencies_on_allocate(session) - await self.emit( - "project_donors", - {"project": project_address, "donors": donations}, - ) + await self.handle_on_allocate( + session, + epochs_contracts, + allocations, + threshold_getter, + estimated_project_rewards, + data, + ) def from_dict(data: dict) -> UserAllocationRequest: @@ -165,12 +282,10 @@ def from_dict(data: dict) -> UserAllocationRequest: signature = payload.get("signature") is_manually_edited = data.get("isManuallyEdited", False) - # fmt: off return UserAllocationRequest( - user_address = user_address, - allocations = allocations, - nonce = nonce, - signature = signature, - is_manually_edited = is_manually_edited, + user_address=user_address, + allocations=allocations, + nonce=nonce, + signature=signature, + is_manually_edited=is_manually_edited, ) - # fmt: on diff --git a/backend/v2/allocations/validators.py b/backend/v2/allocations/validators.py new file mode 100644 index 000000000..be17df9d5 --- /dev/null +++ b/backend/v2/allocations/validators.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass +from web3 import AsyncWeb3 +from app import exceptions +from app.modules.common.crypto.signature import EncodingStandardFor, encode_for_signing +from .schemas import UserAllocationRequest +from .repositories import get_last_allocation_request_nonce +from v2.crypto.signatures import verify_signed_message +from v2.epochs.subgraphs import EpochsSubgraph +from v2.projects.contracts import ProjectsContracts + +from sqlalchemy.ext.asyncio import AsyncSession + +from v2.user_patron_mode.repositories import ( + get_budget_by_user_address_and_epoch, + user_is_patron_with_budget, +) + + +@dataclass +class SignatureVerifier: + session: AsyncSession + epochs_subgraph: EpochsSubgraph + projects_contracts: ProjectsContracts + chain_id: int + + async def verify(self, epoch_number: int, request: UserAllocationRequest) -> None: + await verify_logic( + session=self.session, + epoch_subgraph=self.epochs_subgraph, + projects_contracts=self.projects_contracts, + epoch_number=epoch_number, + payload=request, + ) + await verify_signature( + w3=self.projects_contracts.w3, + chain_id=self.chain_id, + user_address=request.user_address, + payload=request, + ) + + +async def verify_logic( + # Component dependencies + session: AsyncSession, + epoch_subgraph: EpochsSubgraph, + projects_contracts: ProjectsContracts, + # Arguments + epoch_number: int, + payload: UserAllocationRequest, +): + # Check if the epoch is in the decision window + # epoch_details = await epoch_subgraph.get_epoch_by_number(epoch_number) + # if epoch_details.state != "PENDING": + # raise exceptions.NotInDecision + + # Check if the allocations are not empty + if not payload.allocations: + raise exceptions.EmptyAllocations() + + # Check if the nonce is as expected + expected_nonce = await get_next_user_nonce(session, payload.user_address) + if payload.nonce != expected_nonce: + raise exceptions.WrongAllocationsNonce(payload.nonce, expected_nonce) + + # Check if the user is not a patron + epoch_details = await epoch_subgraph.get_epoch_by_number(epoch_number) + is_patron = await user_is_patron_with_budget( + session, + payload.user_address, + epoch_number, + epoch_details.finalized_timestamp.datetime(), + ) + if is_patron: + raise exceptions.NotAllowedInPatronMode(payload.user_address) + + # Check if the user is not a project + all_projects = await projects_contracts.get_project_addresses(epoch_number) + if payload.user_address in all_projects: + raise exceptions.ProjectAllocationToSelf() + + project_addresses = [a.project_address for a in payload.allocations] + + # Check if the projects are valid + invalid_projects = set(project_addresses) - set(all_projects) + if invalid_projects: + raise exceptions.InvalidProjects(invalid_projects) + + # Check if there are no duplicates + duplicates = [p for p in project_addresses if project_addresses.count(p) > 1] + if duplicates: + raise exceptions.DuplicatedProjects(duplicates) + + # Get the user's budget + user_budget = await get_budget_by_user_address_and_epoch( + session, payload.user_address, epoch_number + ) + + if user_budget is None: + raise exceptions.BudgetNotFound(payload.user_address, epoch_number) + + # Check if the allocations are within the budget + if sum(a.amount for a in payload.allocations) > user_budget: + raise exceptions.RewardsBudgetExceeded() + + +async def get_next_user_nonce( + # Component dependencies + session: AsyncSession, + # Arguments + user_address: str, +) -> int: + """ + Get the next expected nonce for the user. + It's a simple increment of the last nonce, or 0 if there is no previous nonce. + """ + # Get the last allocation request of the user + last_allocation_request = await get_last_allocation_request_nonce( + session, user_address + ) + + # Calculate the next nonce + if last_allocation_request is None: + return 0 + + # Increment the last nonce + return last_allocation_request + 1 + + +async def verify_signature( + w3: AsyncWeb3, chain_id: int, user_address: str, payload: UserAllocationRequest +) -> None: + eip712_encoded = build_allocations_eip712_structure(chain_id, payload) + encoded_msg = encode_for_signing(EncodingStandardFor.DATA, eip712_encoded) + + # Verify the signature + is_valid = await verify_signed_message( + w3, user_address, encoded_msg, payload.signature + ) + if not is_valid: + raise exceptions.InvalidSignature(user_address, payload.signature) + + +def build_allocations_eip712_structure(chain_id: int, payload: UserAllocationRequest): + message = {} + message["allocations"] = [ + {"proposalAddress": a.project_address, "amount": a.amount} + for a in payload.allocations + ] + message["nonce"] = payload.nonce # type: ignore + return build_allocations_eip712_data(chain_id, message) + + +def build_allocations_eip712_data(chain_id: int, message: dict) -> dict: + # Convert amount value to int + message["allocations"] = [ + {**allocation, "amount": int(allocation["amount"])} + for allocation in message["allocations"] + ] + + allocation_types = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + ], + "Allocation": [ + {"name": "proposalAddress", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "AllocationPayload": [ + {"name": "allocations", "type": "Allocation[]"}, + {"name": "nonce", "type": "uint256"}, + ], + } + + return { + "types": allocation_types, + "domain": { + "name": "Octant", + "version": "1.0.0", + "chainId": chain_id, + }, + "primaryType": "AllocationPayload", + "message": message, + } diff --git a/backend/v2/core/dependencies.py b/backend/v2/core/dependencies.py index b19f65cda..84325af33 100644 --- a/backend/v2/core/dependencies.py +++ b/backend/v2/core/dependencies.py @@ -1,9 +1,12 @@ +from contextlib import asynccontextmanager +from typing import Annotated, AsyncGenerator + +from fastapi import Depends from app.infrastructure.database.models import BaseModel from pydantic import Field from pydantic_settings import BaseSettings from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import sessionmaker from web3 import AsyncHTTPProvider, AsyncWeb3 from web3.middleware import async_geth_poa_middleware @@ -12,18 +15,17 @@ class Web3ProviderSettings(BaseSettings): eth_rpc_provider_url: str -# TODO: Cache? -def get_w3(eth_rpc_provider_url: str) -> AsyncWeb3: - w3 = AsyncWeb3(provider=AsyncHTTPProvider(eth_rpc_provider_url)) +def get_w3( + settings: Annotated[Web3ProviderSettings, Depends(Web3ProviderSettings)] +) -> AsyncWeb3: + w3 = AsyncWeb3(provider=AsyncHTTPProvider(settings.eth_rpc_provider_url)) if async_geth_poa_middleware not in w3.middleware_onion: w3.middleware_onion.inject(async_geth_poa_middleware, layer=0) return w3 -def w3_getter() -> AsyncWeb3: - settings = Web3ProviderSettings() - return get_w3(settings.eth_rpc_provider_url) +Web3 = Annotated[AsyncWeb3, Depends(get_w3)] class DatabaseSettings(BaseSettings): @@ -38,12 +40,33 @@ async def create_tables(): await conn.run_sync(BaseModel.metadata.create_all) -def get_db_engine(database_uri: str) -> async_sessionmaker[AsyncSession]: - engine = create_async_engine(database_uri) +@asynccontextmanager +async def get_db_session( + settings: Annotated[DatabaseSettings, Depends(DatabaseSettings)] +) -> AsyncGenerator[AsyncSession, None]: + # Create an async SQLAlchemy engine + + # logging.error("Creating database engine") - return sessionmaker(bind=engine, class_=AsyncSession) + engine = create_async_engine(settings.sqlalchemy_database_uri) + # Create a sessionmaker with AsyncSession class + async_session = async_sessionmaker( + autocommit=False, autoflush=False, bind=engine, class_=AsyncSession + ) -def db_getter() -> async_sessionmaker[AsyncSession]: - settings = DatabaseSettings() - return get_db_engine(settings.sqlalchemy_database_uri) + # logging.error("Opening session", async_session) + + # Create a new session + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +AsyncDbSession = Annotated[AsyncSession, Depends(get_db_session)] diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py index 712f2d9da..4cc8acfc5 100644 --- a/backend/v2/epochs/dependencies.py +++ b/backend/v2/epochs/dependencies.py @@ -1,8 +1,8 @@ -from typing import Callable +from typing import Annotated +from fastapi import Depends from pydantic_settings import BaseSettings -from v2.core.dependencies import w3_getter -from web3 import AsyncWeb3 +from v2.core.dependencies import Web3 from .contracts import EPOCHS_ABI, EpochsContracts from .subgraphs import EpochsSubgraph @@ -12,25 +12,17 @@ class EpochsSettings(BaseSettings): epochs_contract_address: str -# TODO: cache -def get_epochs(w3: AsyncWeb3, epochs_contract_address: str) -> EpochsContracts: - return EpochsContracts(w3, EPOCHS_ABI, epochs_contract_address) # type: ignore - - -def epochs_getter() -> EpochsContracts: - settings = EpochsSettings() # type: ignore - return get_epochs(w3_getter(), settings.epochs_contract_address) - - -getter = Callable[[], EpochsContracts] +def get_epochs_contracts( + w3: Web3, settings: Annotated[EpochsSettings, Depends(EpochsSettings)] +) -> EpochsContracts: + return EpochsContracts(w3, EPOCHS_ABI, settings.epochs_contract_address) class EpochsSubgraphSettings(BaseSettings): subgraph_endpoint: str - # url = config["SUBGRAPH_ENDPOINT"] - -def epochs_subgraph_getter() -> EpochsSubgraph: - settings = EpochsSubgraphSettings() # type: ignore +def get_epochs_subgraph( + settings: Annotated[EpochsSubgraphSettings, Depends(EpochsSubgraphSettings)] +) -> EpochsSubgraph: return EpochsSubgraph(settings.subgraph_endpoint) diff --git a/backend/v2/gitcoin_passport/__init__.py b/backend/v2/gitcoin_passport/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/v2/gitcoin_passport/repositories.py b/backend/v2/gitcoin_passport/repositories.py deleted file mode 100644 index 272a12990..000000000 --- a/backend/v2/gitcoin_passport/repositories.py +++ /dev/null @@ -1,19 +0,0 @@ -from app.infrastructure.database.models import GPStamps, User -from eth_utils import to_checksum_address -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - - -async def get_gp_stamps_by_address( - session: AsyncSession, user_address: str -) -> GPStamps | None: - """Gets the latest GitcoinPassport Stamps record for a user.""" - - result = await session.execute( - select(GPStamps) - .join(User) - .filter(User.address == to_checksum_address(user_address)) - .order_by(GPStamps.created_at.desc()) - ) - - return result.scalar_one_or_none() diff --git a/backend/v2/gitcoin_passport/services.py b/backend/v2/gitcoin_passport/services.py deleted file mode 100644 index 786c3cbe9..000000000 --- a/backend/v2/gitcoin_passport/services.py +++ /dev/null @@ -1,25 +0,0 @@ -from app.constants import GUEST_LIST -from app.modules.user.antisybil.service.initial import _has_guest_stamp_applied_by_gp -from eth_utils import to_checksum_address -from sqlalchemy.ext.asyncio import AsyncSession - -from .repositories import get_gp_stamps_by_address - - -async def get_gitcoin_passport_score(session: AsyncSession, user_address: str) -> float: - """Gets saved Gitcoin Passport score for a user. - Returns None if the score is not saved. - If the user is in the GUEST_LIST, the score will be adjusted to include the guest stamp. - """ - - user_address = to_checksum_address(user_address) - - stamps = await get_gp_stamps_by_address(session, user_address) - - if stamps is None: - return 0.0 - - if user_address in GUEST_LIST and not _has_guest_stamp_applied_by_gp(stamps): - return stamps.score + 21.0 - - return stamps.score diff --git a/backend/v2/project_rewards/capped_quadriatic.py b/backend/v2/project_rewards/capped_quadriatic.py index 4e5d1db12..eed699bf4 100644 --- a/backend/v2/project_rewards/capped_quadriatic.py +++ b/backend/v2/project_rewards/capped_quadriatic.py @@ -3,7 +3,7 @@ from math import sqrt from typing import Dict, NamedTuple -from v2.allocations.models import AllocationWithUserUQScore +from v2.allocations.schemas import AllocationWithUserUQScore class CappedQuadriaticFunding(NamedTuple): @@ -136,3 +136,48 @@ def cqf_calculate_individual_leverage( leverage = total_difference / new_allocations_amount return float(leverage) + + +def cqf_simulate_leverage( + existing_allocations: list[AllocationWithUserUQScore], + new_allocations: list[AllocationWithUserUQScore], + matched_rewards: int, + project_addresses: list[str], + MR_FUNDING_CAP_PERCENT: Decimal = MR_FUNDING_CAP_PERCENT, +) -> float: + """Simulate the leverage of a user's new allocations in capped quadratic funding.""" + + if not new_allocations: + raise ValueError("No new allocations provided") + + # Get the user address associated with the allocations + user_address = new_allocations[0].user_address + + # Remove allocations made by this user (as they will be removed in a second) + 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 + before_allocation = capped_quadriatic_funding( + allocations_without_user, + matched_rewards, + project_addresses, + MR_FUNDING_CAP_PERCENT, + ) + after_allocation = capped_quadriatic_funding( + allocations_without_user + new_allocations, + matched_rewards, + project_addresses, + MR_FUNDING_CAP_PERCENT, + ) + + # Calculate leverage + leverage = cqf_calculate_individual_leverage( + new_allocations_amount=sum(a.amount for a in new_allocations), + project_addresses=[a.project_address for a in new_allocations], + before_allocation_matched=before_allocation.matched_by_project, + after_allocation_matched=after_allocation.matched_by_project, + ) + + return leverage diff --git a/backend/v2/projects/depdendencies.py b/backend/v2/projects/depdendencies.py index 49869386d..6b30e7c7a 100644 --- a/backend/v2/projects/depdendencies.py +++ b/backend/v2/projects/depdendencies.py @@ -1,9 +1,19 @@ +from decimal import Decimal +from typing import Annotated +from fastapi import Depends from pydantic import Field from pydantic_settings import BaseSettings -from v2.core.dependencies import w3_getter -from web3 import AsyncWeb3 +from v2.epochs.dependencies import get_epochs_subgraph +from v2.epochs.subgraphs import EpochsSubgraph +from v2.core.dependencies import AsyncDbSession, Web3 + from .contracts import PROJECTS_ABI, ProjectsContracts +from .services import ( + EstimatedProjectMatchedRewards, + EstimatedProjectRewards, + ProjectsAllocationThresholdGetter, +) class ProjectsSettings(BaseSettings): @@ -12,11 +22,70 @@ class ProjectsSettings(BaseSettings): ) -# TODO: cache -def get_projects(w3: AsyncWeb3, projects_contract_address: str) -> ProjectsContracts: - return ProjectsContracts(w3, PROJECTS_ABI, projects_contract_address) # type: ignore +def get_projects_contracts( + w3: Web3, settings: Annotated[ProjectsSettings, Depends(ProjectsSettings)] +) -> ProjectsContracts: + return ProjectsContracts(w3, PROJECTS_ABI, settings.projects_contract_address) + + +class ProjectsAllocationThresholdSettings(BaseSettings): + project_count_multiplier: int = Field( + default=1, + description="The multiplier to the number of projects to calculate the allocation threshold.", + ) + + +def get_projects_allocation_threshold_getter( + session: AsyncDbSession, + projects: Annotated[ProjectsContracts, Depends(get_projects_contracts)], + settings: Annotated[ + ProjectsAllocationThresholdSettings, + Depends(ProjectsAllocationThresholdSettings), + ], +) -> ProjectsAllocationThresholdGetter: + return ProjectsAllocationThresholdGetter( + session, projects, settings.project_count_multiplier + ) + + +class EstimatedProjectMatchedRewardsSettings(BaseSettings): + TR_PERCENT: Decimal = Field( + default=Decimal("0.7"), description="The percentage of the TR rewards." + ) + IRE_PERCENT: Decimal = Field( + default=Decimal("0.35"), description="The percentage of the IRE rewards." + ) + MATCHED_REWARDS_PERCENT: Decimal = Field( + default=Decimal("0.35"), description="The percentage of the matched rewards." + ) + + +def get_estimated_project_matched_rewards( + session: AsyncDbSession, + epochs_subgraph: Annotated[EpochsSubgraph, Depends(get_epochs_subgraph)], + settings: Annotated[ + EstimatedProjectMatchedRewardsSettings, + Depends(EstimatedProjectMatchedRewardsSettings), + ], +) -> EstimatedProjectMatchedRewards: + return EstimatedProjectMatchedRewards( + session=session, + epochs_subgraph=epochs_subgraph, + tr_percent=settings.TR_PERCENT, + ire_percent=settings.IRE_PERCENT, + matched_rewards_percent=settings.MATCHED_REWARDS_PERCENT, + ) -def projects_getter() -> ProjectsContracts: - settings = ProjectsSettings() # type: ignore - return get_projects(w3_getter(), settings.projects_contract_address) +def get_estimated_project_rewards( + session: AsyncDbSession, + projects: Annotated[ProjectsContracts, Depends(get_projects_contracts)], + estimated_project_matched_rewards: Annotated[ + EstimatedProjectMatchedRewards, Depends(get_estimated_project_matched_rewards) + ], +) -> EstimatedProjectRewards: + return EstimatedProjectRewards( + session=session, + projects=projects, + estimated_matched_rewards=estimated_project_matched_rewards, + ) diff --git a/backend/v2/projects/services.py b/backend/v2/projects/services.py index c9f2d0463..94e787b9f 100644 --- a/backend/v2/projects/services.py +++ b/backend/v2/projects/services.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from decimal import Decimal from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +16,24 @@ from v2.user_patron_mode.repositories import get_patrons_rewards +@dataclass +class ProjectsAllocationThresholdGetter: + session: AsyncSession + projects: ProjectsContracts + project_count_multiplier: int = 1 + + async def get( + self, + epoch_number: int, + ) -> int: + return await get_projects_allocation_threshold( + session=self.session, + projects=self.projects, + epoch_number=epoch_number, + project_count_multiplier=self.project_count_multiplier, + ) + + async def get_projects_allocation_threshold( # Dependencies session: AsyncSession, @@ -48,41 +67,35 @@ def _calculate_threshold( ) -async def get_estimated_project_rewards( +@dataclass +class EstimatedProjectMatchedRewards: # Dependencies - session: AsyncSession, - projects: ProjectsContracts, - epochs_subgraph: EpochsSubgraph, - # Arguments - epoch_number: int, -) -> CappedQuadriaticFunding: - # project_settings project is ProjectSettings - all_projects = await projects.get_project_addresses(epoch_number) - - matched_rewards = await get_estimated_project_matched_rewards_pending( - session, - epochs_subgraph=epochs_subgraph, - epoch_number=epoch_number, - ) - allocations = await get_allocations_with_user_uqs(session, epoch_number) - - return capped_quadriatic_funding( - project_addresses=all_projects, - allocations=allocations, - matched_rewards=matched_rewards, - ) - - -TR_PERCENT = Decimal("0.7") -IRE_PERCENT = Decimal("0.35") -MATCHED_REWARDS_PERCENT = Decimal("0.35") + session: AsyncSession + epochs_subgraph: EpochsSubgraph + # Settings + tr_percent: Decimal + ire_percent: Decimal + matched_rewards_percent: Decimal + + async def get(self, epoch_number: int) -> int: + return await get_estimated_project_matched_rewards_pending( + session=self.session, + epochs_subgraph=self.epochs_subgraph, + tr_percent=self.tr_percent, + ire_percent=self.ire_percent, + matched_rewards_percent=self.matched_rewards_percent, + epoch_number=epoch_number, + ) async def get_estimated_project_matched_rewards_pending( # Dependencies session: AsyncSession, epochs_subgraph: EpochsSubgraph, - # projects: Projects, + # Settings + tr_percent: Decimal, + ire_percent: Decimal, + matched_rewards_percent: Decimal, # Arguments epoch_number: int, ) -> int: @@ -99,16 +112,14 @@ async def get_estimated_project_matched_rewards_pending( session, epoch_details.finalized_timestamp.datetime(), epoch_number ) - # fmt: off return _calculate_percentage_matched_rewards( - locked_ratio = Decimal(pending_snapshot.locked_ratio), - tr_percent = TR_PERCENT, - ire_percent = IRE_PERCENT, - staking_proceeds = int(pending_snapshot.eth_proceeds), - patrons_rewards = patrons_rewards, - matched_rewards_percent = MATCHED_REWARDS_PERCENT, + locked_ratio=Decimal(pending_snapshot.locked_ratio), + tr_percent=tr_percent, + ire_percent=ire_percent, + staking_proceeds=int(pending_snapshot.eth_proceeds), + patrons_rewards=patrons_rewards, + matched_rewards_percent=matched_rewards_percent, ) - # fmt: on def _calculate_percentage_matched_rewards( @@ -124,6 +135,44 @@ def _calculate_percentage_matched_rewards( if locked_ratio < ire_percent: return int(matched_rewards_percent * staking_proceeds + patrons_rewards) - elif ire_percent <= locked_ratio < tr_percent: + + if ire_percent <= locked_ratio < tr_percent: return int((tr_percent - locked_ratio) * staking_proceeds + patrons_rewards) + return patrons_rewards + + +@dataclass +class EstimatedProjectRewards: + # Dependencies + session: AsyncSession + projects: ProjectsContracts + estimated_matched_rewards: EstimatedProjectMatchedRewards + + async def get(self, epoch_number: int) -> CappedQuadriaticFunding: + return await estimate_project_rewards( + session=self.session, + projects=self.projects, + estimated_matched_rewards=self.estimated_matched_rewards, + epoch_number=epoch_number, + ) + + +async def estimate_project_rewards( + # Dependencies + session: AsyncSession, + projects: ProjectsContracts, + estimated_matched_rewards: EstimatedProjectMatchedRewards, + # Arguments + epoch_number: int, +) -> CappedQuadriaticFunding: + # project_settings project is ProjectSettings + all_projects = await projects.get_project_addresses(epoch_number) + matched_rewards = await estimated_matched_rewards.get(epoch_number) + allocations = await get_allocations_with_user_uqs(session, epoch_number) + + return capped_quadriatic_funding( + project_addresses=all_projects, + allocations=allocations, + matched_rewards=matched_rewards, + ) diff --git a/backend/v2/uniqueness_quotients/dependencies.py b/backend/v2/uniqueness_quotients/dependencies.py new file mode 100644 index 000000000..46739bbb2 --- /dev/null +++ b/backend/v2/uniqueness_quotients/dependencies.py @@ -0,0 +1,36 @@ +from decimal import Decimal +from typing import Annotated +from fastapi import Depends + +from pydantic import Field +from pydantic_settings import BaseSettings + +from v2.core.dependencies import AsyncDbSession +from .services import UQScoreGetter + + +class UQScoreSettings(BaseSettings): + uq_score_threshold: float = Field( + default=21.0, + description="The Gitcoin Passport score threshold above which the UQ score is set to the maximum UQ score.", + ) + low_uq_score: Decimal = Field( + default=Decimal("0.2"), + description="The UQ score to be returned if the Gitcoin Passport score is below the threshold.", + ) + max_uq_score: Decimal = Field( + default=Decimal("1.0"), + description="The UQ score to be returned if the Gitcoin Passport score is above the threshold.", + ) + + +def get_uq_score_getter( + session: AsyncDbSession, + settings: Annotated[UQScoreSettings, Depends(UQScoreSettings)], +) -> UQScoreGetter: + return UQScoreGetter( + session=session, + uq_score_threshold=settings.uq_score_threshold, + max_uq_score=settings.max_uq_score, + low_uq_score=settings.low_uq_score, + ) diff --git a/backend/v2/uniqueness_quotients/repositories.py b/backend/v2/uniqueness_quotients/repositories.py index 14e485fc2..c9d3376ed 100644 --- a/backend/v2/uniqueness_quotients/repositories.py +++ b/backend/v2/uniqueness_quotients/repositories.py @@ -1,7 +1,7 @@ from decimal import Decimal from typing import Optional -from app.infrastructure.database.models import UniquenessQuotient, User +from app.infrastructure.database.models import GPStamps, UniquenessQuotient, User from eth_utils import to_checksum_address from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -43,3 +43,18 @@ async def save_uq_score_for_user_address( ) session.add(uq_score) + + +async def get_gp_stamps_by_address( + session: AsyncSession, user_address: str +) -> GPStamps | None: + """Gets the latest GitcoinPassport Stamps record for a user.""" + + result = await session.execute( + select(GPStamps) + .join(User) + .filter(User.address == to_checksum_address(user_address)) + .order_by(GPStamps.created_at.desc()) + ) + + return result.scalar_one_or_none() diff --git a/backend/v2/uniqueness_quotients/services.py b/backend/v2/uniqueness_quotients/services.py index aaafd0522..56308d71f 100644 --- a/backend/v2/uniqueness_quotients/services.py +++ b/backend/v2/uniqueness_quotients/services.py @@ -1,19 +1,42 @@ +from dataclasses import dataclass from decimal import Decimal from sqlalchemy.ext.asyncio import AsyncSession -from v2.gitcoin_passport.services import get_gitcoin_passport_score -from .repositories import get_uq_score_by_user_address, save_uq_score_for_user_address +from app.constants import GUEST_LIST +from app.modules.user.antisybil.service.initial import _has_guest_stamp_applied_by_gp +from eth_utils import to_checksum_address -LOW_UQ_SCORE = Decimal("0.2") -MAX_UQ_SCORE = Decimal("1.0") +from .repositories import ( + get_uq_score_by_user_address, + save_uq_score_for_user_address, + get_gp_stamps_by_address, +) + + +@dataclass +class UQScoreGetter: + session: AsyncSession + uq_score_threshold: float + max_uq_score: Decimal + low_uq_score: Decimal + + async def get_or_calculate(self, epoch_number: int, user_address: str) -> Decimal: + return await get_or_calculate_uq_score( + session=self.session, + user_address=user_address, + epoch_number=epoch_number, + uq_score_threshold=self.uq_score_threshold, + max_uq_score=self.max_uq_score, + low_uq_score=self.low_uq_score, + ) def calculate_uq_score( gp_score: float, uq_score_threshold: float, - max_uq_score: Decimal = MAX_UQ_SCORE, - low_uq_score: Decimal = LOW_UQ_SCORE, + max_uq_score: Decimal, + low_uq_score: Decimal, ) -> Decimal: """Calculate UQ score (multiplier) based on the GP score and the UQ score threshold. If the GP score is greater than or equal to the UQ score threshold, the UQ score is set to the maximum UQ score. @@ -22,6 +45,8 @@ def calculate_uq_score( Args: gp_score (float): The GitcoinPassport antisybil score. uq_score_threshold (int): Anything below this threshold will be considered low UQ score, and anything above will be considered maximum UQ score. + max_uq_score (Decimal): Score to be returned if the GP score is greater than or equal to the UQ score threshold. + low_uq_score (Decimal): Score to be returned if the GP score is less than the UQ score threshold. """ if gp_score >= uq_score_threshold: @@ -35,8 +60,8 @@ async def get_or_calculate_uq_score( user_address: str, epoch_number: int, uq_score_threshold: float, - max_uq_score: Decimal = MAX_UQ_SCORE, - low_uq_score: Decimal = LOW_UQ_SCORE, + max_uq_score: Decimal, + low_uq_score: Decimal, ) -> Decimal: """Get or calculate the UQ score for a user in a given epoch. If the UQ score is already calculated, it will be returned. @@ -58,3 +83,22 @@ async def get_or_calculate_uq_score( await save_uq_score_for_user_address(session, user_address, epoch_number, uq_score) return uq_score + + +async def get_gitcoin_passport_score(session: AsyncSession, user_address: str) -> float: + """Gets saved Gitcoin Passport score for a user. + Returns None if the score is not saved. + If the user is in the GUEST_LIST, the score will be adjusted to include the guest stamp. + """ + + user_address = to_checksum_address(user_address) + + stamps = await get_gp_stamps_by_address(session, user_address) + + if stamps is None: + return 0.0 + + if user_address in GUEST_LIST and not _has_guest_stamp_applied_by_gp(stamps): + return stamps.score + 21.0 + + return stamps.score diff --git a/backend/v2/user_patron_mode/repositories.py b/backend/v2/user_patron_mode/repositories.py index 38c16c174..7ac9672b2 100644 --- a/backend/v2/user_patron_mode/repositories.py +++ b/backend/v2/user_patron_mode/repositories.py @@ -31,7 +31,7 @@ async def get_all_patrons_at_timestamp( result = await session.execute( select(alias.user_address) - .filter(alias.patron_mode_enabled == True) + .filter(alias.patron_mode_enabled) .group_by(alias.user_address) )