From 0f28150b84ba0f0aac7c4a9ecc15f9ab4d36aebd Mon Sep 17 00:00:00 2001 From: adam-gf Date: Mon, 5 Aug 2024 11:40:28 +0200 Subject: [PATCH 1/6] wip - failing app context --- backend/app/extensions.py | 8 ++- .../app/infrastructure/contracts/epochs.py | 1 + backend/app/infrastructure/events.py | 64 +++++++++++------ backend/poetry.lock | 41 ++++++++++- backend/pyproject.toml | 1 + backend/startup.py | 70 +++++++++++++------ 6 files changed, 141 insertions(+), 44 deletions(-) diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 8a749b7c8..17db5d23e 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -1,3 +1,4 @@ +import socketio from flask_apscheduler import APScheduler from flask_cors import CORS from flask_migrate import Migrate @@ -23,7 +24,12 @@ description="Octant REST API documentation", catch_all_404s=True, ) -socketio = SocketIO(cors_allowed_origins="*") +# from flask import current_app as app + +socketio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", logger=True) + # async_mode="asgi", cors_allowed_origins="*", client_manager=mgr +# ) +# socketio = SocketIO(cors_allowed_origins="*") db = SQLAlchemy() migrate = Migrate() cors = CORS() diff --git a/backend/app/infrastructure/contracts/epochs.py b/backend/app/infrastructure/contracts/epochs.py index b2c95ada8..bb47df0f3 100644 --- a/backend/app/infrastructure/contracts/epochs.py +++ b/backend/app/infrastructure/contracts/epochs.py @@ -27,6 +27,7 @@ def get_current_epoch(self) -> int: def get_pending_epoch(self) -> Optional[int]: try: app.logger.debug("[Epochs contract] Getting pending epoch") + return 5 return self.contract.functions.getPendingEpoch().call() except exceptions.ContractLogicError: app.logger.warning("[Epochs contract] No pending epoch") diff --git a/backend/app/infrastructure/events.py b/backend/app/infrastructure/events.py index 86ba0f99c..87af413de 100644 --- a/backend/app/infrastructure/events.py +++ b/backend/app/infrastructure/events.py @@ -1,8 +1,8 @@ import json from typing import List -from flask import current_app as app -from flask_socketio import emit +from flask import current_app +# from flask_socketio import emit from app.engine.projects.rewards import ProjectRewardDTO from app.exceptions import OctantException @@ -17,25 +17,42 @@ @socketio.on("connect") -def handle_connect(): - app.logger.debug("Client connected") +async def handle_connect(sid: str, environ: dict): - if epochs.get_pending_epoch() is not None: - threshold = get_allocation_threshold() - emit("threshold", {"threshold": str(threshold)}) + print("Type of sid", type(sid)) + print("Type of environ", type(environ)) - project_rewards = get_estimated_project_rewards().rewards - emit("project_rewards", _serialize_project_rewards(project_rewards)) + # socketio.logger.debug("Client connected") + # app_instance = current_app._get_current_object() + with current_app.app_context(): + + current_app.logger.debug("Cl/ient connected") + + print("Epochs are here") + + await socketio.emit("epoch", {"epoch": "fuckup"}) + + if epochs.get_pending_epoch() is not None: + threshold = get_allocation_threshold() + await socketio.emit("threshold", {"threshold": str(threshold)}) + + project_rewards = get_estimated_project_rewards().rewards + await socketio.emit("project_rewards", _serialize_project_rewards(project_rewards)) @socketio.on("disconnect") -def handle_disconnect(): - app.logger.debug("Client disconnected") +async def handle_disconnect(sid): + socketio.logger.debug("Client disconnected") @socketio.on("allocate") -def handle_allocate(msg): +async def handle_allocate(sid, msg): + + print("message", msg) msg = json.loads(msg) + + print("MEssage", msg) + is_manually_edited = msg["isManuallyEdited"] if "isManuallyEdited" in msg else None user_address = msg["userAddress"] app.logger.info(f"User allocation payload: {msg}") @@ -44,7 +61,7 @@ def handle_allocate(msg): msg, is_manually_edited=is_manually_edited, ) - app.logger.info(f"User: {user_address} allocated successfully") + socketio.logger.info(f"User: {user_address} allocated successfully") threshold = get_allocation_threshold() emit("threshold", {"threshold": str(threshold)}, broadcast=True) @@ -64,16 +81,21 @@ def handle_allocate(msg): ) -@socketio.on("project_donors") -def handle_project_donors(project_address: str): - donors = controller.get_all_donations_by_project(project_address) - emit( - "project_donors", - {"project": project_address, "donors": _serialize_donors(donors)}, - ) +# @socketio.on("project_donors") +# def handle_project_donors(project_address: str): +# print("Project donors") +# emit( +# "project_donors", +# {"project": project_address, "donors": []}, +# ) +# donors = controller.get_all_donations_by_project(project_address) +# emit( +# "project_donors", +# {"project": project_address, "donors": _serialize_donors(donors)}, +# ) -@socketio.on_error_default +# @socketio. def default_error_handler(e): ExceptionHandler.print_stacktrace(e) if isinstance(e, OctantException): diff --git a/backend/poetry.lock b/backend/poetry.lock index 209ea55a9..306715b6b 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "aiohttp" @@ -1103,6 +1103,26 @@ dnspython = ">=1.15.0" greenlet = ">=0.3" six = ">=1.10.0" +[[package]] +name = "fastapi" +version = "0.112.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.112.0-py3-none-any.whl", hash = "sha256:3487ded9778006a45834b8c816ec4a48d522e2631ca9e75ec5a774f1b052f821"}, + {file = "fastapi-0.112.0.tar.gz", hash = "sha256:d262bc56b7d101d1f4e8fc0ad2ac75bb9935fec504d2b7117686cec50710cf05"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.37.2,<0.38.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "flake8" version = "6.1.0" @@ -3202,6 +3222,23 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "starlette" +version = "0.37.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, + {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + [[package]] name = "toolz" version = "0.12.1" @@ -3521,4 +3558,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8beb2e0b06481e87b431a937a21c11d21f06810f5b7497781c4be087d48e4b44" +content-hash = "07a0348e2a44a40f17ba9605b8de32af30fd335a64b6fd9559bce5a3cd821c76" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 683adf29a..12df28a42 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,7 @@ pandas = "^2.2.0" gmpy2 = "^2.1.5" sentry-sdk = {extras = ["flask"], version = "^2.5.1"} redis = "^5.0.7" +fastapi = "^0.112.0" [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" diff --git a/backend/startup.py b/backend/startup.py index f100cc84d..5603127e6 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -1,21 +1,14 @@ -# !!! IMPORTANT: DO NOT REARRANGE IMPORTS IN THIS FILE !!! -# The eventlet monkey patch needs to be applied before importing the Flask application for the following reasons: -# 1. Enabling Asynchronous I/O: The monkey patch is required to activate eventlet’s asynchronous and non-blocking I/O capabilities. -# Without this patch, the app's I/O requests might be blocked, which is not desirable for our API's performance. -# 2. Import Order Significance: The monkey patch must be applied before importing the Flask application to ensure that the app utilizes -# the asynchronous versions of standard library modules that have been patched by eventlet. If not done in this order, we might experience issues similar to -# what is reported in the following eventlet issue: https://github.com/eventlet/eventlet/issues/371 -# This comment provides additional insight and helped resolve our specific problem: https://github.com/eventlet/eventlet/issues/371#issuecomment-779967181 -# 3. Issue with dnspython: If dnspython is present in the environment, eventlet monkeypatches socket.getaddrinfo(), -# which breaks dns functionality. By setting the EVENTLET_NO_GREENDNS environment variable before importing eventlet, -# we prevent this monkeypatching - +import asyncio +from concurrent.futures import ThreadPoolExecutor +import io import os +from fastapi import FastAPI, Request +from fastapi.middleware.wsgi import WSGIMiddleware +from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse +from starlette.responses import Response -os.environ["EVENTLET_NO_GREENDNS"] = "yes" -import eventlet # noqa +from starlette.middleware.base import BaseHTTPMiddleware -eventlet.monkey_patch() if os.getenv("SENTRY_DSN"): import sentry_sdk @@ -31,13 +24,50 @@ from app import create_app # noqa from app.extensions import db # noqa -app = create_app() - +# Create Flask app +flask_app = create_app() -@app.teardown_request +@flask_app.teardown_request def teardown_session(*args, **kwargs): db.session.remove() -if __name__ == "__main__": - eventlet.wsgi.server(eventlet.listen(("0.0.0.0", 5000)), app, log=app.logger) +# Create FastAPI app +fastapi_app = FastAPI() + +@fastapi_app.get("/fastapi-endpoint") +async def fastapi_endpoint(): + return {"message": "This is a FastAPI endpoint."} + +# 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): + path = request.url.path + # Check if the path exists in FastAPI routes + for route in fastapi_app.routes: + if path == route.path: + # If path exists, proceed with the request + return await call_next(request) + # If path does not exist, modify the request to forward to the Flask app + if path.startswith('/flask'): + return await call_next(request) + request.scope['path'] = '/flask' + path # Adjust the path as needed + response = await call_next(request) + return response + + +fastapi_app.add_middleware(PathCheckMiddleware) + + +from app.extensions import socketio as our_socketio +import socketio + +sio_asgi_app = socketio.ASGIApp(socketio_server=our_socketio, other_asgi_app=fastapi_app) + +# app.mount("/static", StaticFiles(directory="static"), name="static") +fastapi_app.add_route("/socket.io/", route=sio_asgi_app) +fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) From e2310d10e07114a78a9dc591eeaf70f7ae8c4f1b Mon Sep 17 00:00:00 2001 From: adam-gf Date: Tue, 13 Aug 2024 10:57:12 +0200 Subject: [PATCH 2/6] Throwing more wip --- backend/app/extensions.py | 1 + backend/app/logging.py | 5 + backend/startup.py | 22 ++-- backend/v2/__init__.py | 0 backend/v2/allocations/__init__.py | 0 backend/v2/allocations/repositories.py | 18 +++ backend/v2/allocations/socket.py | 163 +++++++++++++++++++++++++ backend/v2/core/__init__.py | 0 backend/v2/core/contracts.py | 11 ++ backend/v2/core/dependencies.py | 14 +++ backend/v2/epochs/__init__.py | 0 backend/v2/epochs/contracts.py | 155 +++++++++++++++++++++++ backend/v2/epochs/dependencies.py | 8 ++ backend/v2/epochs/repositories.py | 12 ++ backend/v2/main.py | 26 ++++ backend/v2/projects/__init__.py | 0 backend/v2/projects/contracts.py | 33 +++++ backend/v2/projects/depdendencies.py | 9 ++ backend/v2/projects/services.py | 123 +++++++++++++++++++ 19 files changed, 590 insertions(+), 10 deletions(-) create mode 100644 backend/v2/__init__.py create mode 100644 backend/v2/allocations/__init__.py create mode 100644 backend/v2/allocations/repositories.py create mode 100644 backend/v2/allocations/socket.py create mode 100644 backend/v2/core/__init__.py create mode 100644 backend/v2/core/contracts.py create mode 100644 backend/v2/core/dependencies.py create mode 100644 backend/v2/epochs/__init__.py create mode 100644 backend/v2/epochs/contracts.py create mode 100644 backend/v2/epochs/dependencies.py create mode 100644 backend/v2/epochs/repositories.py create mode 100644 backend/v2/main.py create mode 100644 backend/v2/projects/__init__.py create mode 100644 backend/v2/projects/contracts.py create mode 100644 backend/v2/projects/depdendencies.py create mode 100644 backend/v2/projects/services.py diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 17db5d23e..40cf6528f 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -27,6 +27,7 @@ # from flask import current_app as app socketio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", logger=True) + # async_mode="asgi", cors_allowed_origins="*", client_manager=mgr # ) # socketio = SocketIO(cors_allowed_origins="*") diff --git a/backend/app/logging.py b/backend/app/logging.py index 958eb9c0e..239b8ec05 100644 --- a/backend/app/logging.py +++ b/backend/app/logging.py @@ -58,6 +58,11 @@ def config(app_level): "apscheduler.executors.default": { "level": "WARNING", }, + "uvicorn": { # Adding for the uvicorn logger (FastAPI) + "level": app_level, + "handlers": ["stdout", "stderr"], + "propagate": 0, + }, }, } diff --git a/backend/startup.py b/backend/startup.py index 5603127e6..f6a4b8159 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -32,12 +32,14 @@ def teardown_session(*args, **kwargs): db.session.remove() +from v2.main import fastapi_app + # Create FastAPI app -fastapi_app = FastAPI() +# fastapi_app = FastAPI() -@fastapi_app.get("/fastapi-endpoint") -async def fastapi_endpoint(): - return {"message": "This is a FastAPI endpoint."} +# @fastapi_app.get("/fastapi-endpoint") +# async def fastapi_endpoint(): +# return {"message": "This is a FastAPI endpoint."} # Mount Flask app under a sub-path fastapi_app.mount("/flask", WSGIMiddleware(flask_app)) @@ -63,11 +65,11 @@ async def dispatch(self, request: Request, call_next): fastapi_app.add_middleware(PathCheckMiddleware) -from app.extensions import socketio as our_socketio -import socketio +# from app.extensions import socketio as our_socketio +# import socketio -sio_asgi_app = socketio.ASGIApp(socketio_server=our_socketio, other_asgi_app=fastapi_app) +# sio_asgi_app = socketio.ASGIApp(socketio_server=our_socketio, other_asgi_app=fastapi_app) -# app.mount("/static", StaticFiles(directory="static"), name="static") -fastapi_app.add_route("/socket.io/", route=sio_asgi_app) -fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) +# # app.mount("/static", StaticFiles(directory="static"), name="static") +# fastapi_app.add_route("/socket.io/", route=sio_asgi_app) +# fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) diff --git a/backend/v2/__init__.py b/backend/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/allocations/__init__.py b/backend/v2/allocations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/allocations/repositories.py b/backend/v2/allocations/repositories.py new file mode 100644 index 000000000..0a0bb84dc --- /dev/null +++ b/backend/v2/allocations/repositories.py @@ -0,0 +1,18 @@ + + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + + +from backend.app.infrastructure.database.models import Allocation + + +async def sum_allocations_by_epoch(session: AsyncSession, epoch: int) -> int: + """Get the sum of all allocations for a given epoch. We only consider the allocations that have not been deleted. + """ + + result = await session.execute( + select(func.sum(Allocation.amount)).filter(Allocation.epoch == epoch).filter(Allocation.deleted_at.is_(None)) + ) + count = result.scalar() + return count \ No newline at end of file diff --git a/backend/v2/allocations/socket.py b/backend/v2/allocations/socket.py new file mode 100644 index 000000000..6b84064cb --- /dev/null +++ b/backend/v2/allocations/socket.py @@ -0,0 +1,163 @@ +import json +import logging +from typing import List + +from flask import current_app +import socketio +# from flask_socketio import emit + +from app.engine.projects.rewards import ProjectRewardDTO +from app.exceptions import OctantException +# from app.extensions import socketio, epochs +from app.infrastructure.exception_handler import UNEXPECTED_EXCEPTION, ExceptionHandler + +from app.modules.dto import ProjectDonationDTO +from app.modules.projects.rewards.controller import get_estimated_project_rewards +from app.modules.user.allocations import controller + + +from backend.v2.core.dependencies import get_w3 +from backend.v2.epochs.contracts import Epochs +from backend.v2.epochs.dependencies import get_epochs +from backend.v2.projects.contracts import Projects + +from backend.v2.projects.depdendencies import get_projects +from backend.v2.projects.services import get_projects_allocation_threshold + + +class AllocateNamespace(socketio.AsyncNamespace): + + def __init__(self, namespace: str): + super().__init__(namespace=namespace) + + self.w3 = get_w3() + self.settings = get_settings() + self.epochs = get_epochs(self.w3, self.settings.epochs_contract_address) + self.projects = get_projects(self.w3, self.settings.projects_contract_address) + + async def on_connect(self, sid: str, environ: dict): + """ + Handle client connection + """ + + print("Type of sid", type(sid)) + print("Type of environ", type(environ)) + + # socketio.logger.debug("Client connected") + # app_instance = current_app._get_current_object() + + logging.debug("Client connected") + + print("Epochs are here") + + await self.emit("epoch", {"epoch": "fuckup"}) + + # We send the data only in PENDING state + pending_epoch_number = await self.epochs.get_pending_epoch() + + # We do not handle requests outside of pending epoch state + if pending_epoch_number is None: + return + + + threshold = await get_projects_allocation_threshold( + session=self.session, # TODO: + projects=self.projects, + epoch_number=pending_epoch_number + ) + + await self.emit("threshold", {"threshold": str(threshold)}) + + project_rewards = get_estimated_project_rewards().rewards + await self.emit("project_rewards", _serialize_project_rewards(project_rewards)) + + + async def on_disconnect(self, sid): + + logging.debug("Client disconnected") + + + async def on_allocate(self, sid: str, environ: dict) -> None: + + + print("message", msg) + msg = json.loads(msg) + + print("MEssage", msg) + + is_manually_edited = msg["isManuallyEdited"] if "isManuallyEdited" in msg else None + user_address = msg["userAddress"] + logging.info(f"User allocation payload: {msg}") + controller.allocate( + user_address, + msg, + is_manually_edited=is_manually_edited, + ) + socketio.logger.info(f"User: {user_address} allocated successfully") + + threshold = get_projects_allocation_threshold() + await self.emit("threshold", {"threshold": str(threshold)}, broadcast=True) + + project_rewards = get_estimated_project_rewards().rewards + await self.emit( + "project_rewards", + _serialize_project_rewards(project_rewards), + broadcast=True, + ) + for project in project_rewards: + donors = controller.get_all_donations_by_project(project.address) + await self.emit( + "project_donors", + {"project": project.address, "donors": _serialize_donors(donors)}, + broadcast=True, + ) + + +# def state_context(epoch_state: EpochState) -> Context: +# epoch_num = get_epoch_number(epoch_state) +# return build_context(epoch_num, epoch_state, with_block_range) + + + +# @socketio.on("project_donors") +# def handle_project_donors(project_address: str): +# print("Project donors") +# emit( +# "project_donors", +# {"project": project_address, "donors": []}, +# ) +# donors = controller.get_all_donations_by_project(project_address) +# emit( +# "project_donors", +# {"project": project_address, "donors": _serialize_donors(donors)}, +# ) + + +# @socketio. +def default_error_handler(e): + ExceptionHandler.print_stacktrace(e) + if isinstance(e, OctantException): + emit("exception", {"message": str(e.message)}) + else: + emit("exception", {"message": UNEXPECTED_EXCEPTION}) + + +def _serialize_project_rewards(project_rewards: List[ProjectRewardDTO]) -> List[dict]: + return [ + { + "address": project_reward.address, + "allocated": str(project_reward.allocated), + "matched": str(project_reward.matched), + } + for project_reward in project_rewards + ] + + +def _serialize_donors(donors: List[ProjectDonationDTO]) -> List[dict]: + return [ + { + "address": donor.donor, + "amount": str(donor.amount), + } + for donor in donors + ] diff --git a/backend/v2/core/__init__.py b/backend/v2/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/core/contracts.py b/backend/v2/core/contracts.py new file mode 100644 index 000000000..2ffe188ea --- /dev/null +++ b/backend/v2/core/contracts.py @@ -0,0 +1,11 @@ + +from web3 import AsyncWeb3 +from web3.contract import AsyncContract +from web3.types import ABI + + +class SmartContract: + def __init__(self, w3: AsyncWeb3, abi: ABI, address: str) -> None: + self.abi = abi + self.w3 = w3 + self.contract: AsyncContract = w3.eth.contract(address=address, abi=abi) diff --git a/backend/v2/core/dependencies.py b/backend/v2/core/dependencies.py new file mode 100644 index 000000000..0a7a0af65 --- /dev/null +++ b/backend/v2/core/dependencies.py @@ -0,0 +1,14 @@ +from web3 import AsyncWeb3 +from web3.middleware import async_geth_poa_middleware + + +# TODO: Cache? +def get_w3(web3_provider: str) -> AsyncWeb3: + + w3 = AsyncWeb3() + w3.provider = web3_provider + if async_geth_poa_middleware not in w3.middleware_onion: + w3.middleware_onion.inject(async_geth_poa_middleware, layer=0) + + return w3 + diff --git a/backend/v2/epochs/__init__.py b/backend/v2/epochs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/epochs/contracts.py b/backend/v2/epochs/contracts.py new file mode 100644 index 000000000..a4614f7fd --- /dev/null +++ b/backend/v2/epochs/contracts.py @@ -0,0 +1,155 @@ +from typing import Dict, Literal, Optional, TypedDict + +from web3 import exceptions + +import logging + +from backend.v2.core.contracts import SmartContract + + +class Epochs(SmartContract): + + async def is_decision_window_open(self) -> bool: + logging.debug("[Epochs contract] Checking if decision window is open") + return await self.contract.functions.isDecisionWindowOpen().call() + + async def get_decision_window(self) -> bool: + logging.debug("[Epochs contract] Checking decision window length") + return await self.contract.functions.getDecisionWindow().call() + + async def get_current_epoch(self) -> int: + try: + logging.debug("[Epochs contract] Getting current epoch") + return await self.contract.functions.getCurrentEpoch().call() + except exceptions.ContractLogicError: + logging.warning("[Epochs contract] Current epoch not started yet") + # HN:Epochs/not-started-yet + return 0 + + async def get_pending_epoch(self) -> Optional[int]: + try: + logging.debug("[Epochs contract] Getting pending epoch") + # return 5 + return await self.contract.functions.getPendingEpoch().call() + except exceptions.ContractLogicError: + logging.warning("[Epochs contract] No pending epoch") + # HN:Epochs/not-pending + return None + + async def get_finalized_epoch(self) -> int: + try: + logging.debug("[Epochs contract] Getting finalized epoch") + return await self.contract.functions.getFinalizedEpoch().call() + except exceptions.ContractLogicError: + logging.warning("[Epochs contract] No finalized epoch") + # HN:Epochs/not-finalized + return 0 + + async def get_current_epoch_end(self) -> int: + logging.debug("[Epochs contract] Checking when current epoch ends") + return await self.contract.functions.getCurrentEpochEnd().call() + + 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: + logging.debug("[Epochs contract] Getting epoch props index") + index = await self.contract.functions.epochPropsIndex().call() + logging.debug("[Epochs contract] Getting next epoch props") + return await self.contract.functions.epochProps(index).call() + + async def is_started(self) -> bool: + logging.debug("[Epochs contract] Checking if first epoch has started") + return await self.contract.functions.isStarted().call() + + async def start(self) -> int: + logging.debug("[Epochs contract] Checking when first epochs starts") + return await self.contract.functions.start().call() + + +EPOCHS_ABI = [ + { + "inputs": [], + "name": "getCurrentEpoch", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "getCurrentEpochEnd", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "getPendingEpoch", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "getFinalizedEpoch", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "getEpochDuration", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "getDecisionWindow", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "isDecisionWindowOpen", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "name": "epochProps", + "outputs": [ + {"internalType": "uint32", "name": "from", "type": "uint32"}, + {"internalType": "uint32", "name": "to", "type": "uint32"}, + {"internalType": "uint64", "name": "fromTs", "type": "uint64"}, + {"internalType": "uint64", "name": "duration", "type": "uint64"}, + {"internalType": "uint64", "name": "decisionWindow", "type": "uint64"}, + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "epochPropsIndex", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "isStarted", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "start", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, +] \ No newline at end of file diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py new file mode 100644 index 000000000..a32f25b81 --- /dev/null +++ b/backend/v2/epochs/dependencies.py @@ -0,0 +1,8 @@ +from web3 import AsyncWeb3 +from .contracts import Epochs, EPOCHS_ABI + + +# TODO: cache +def get_epochs(w3: AsyncWeb3, epochs_contract_address: str) -> Epochs: + + return Epochs(w3, EPOCHS_ABI, epochs_contract_address) diff --git a/backend/v2/epochs/repositories.py b/backend/v2/epochs/repositories.py new file mode 100644 index 000000000..33aa9178a --- /dev/null +++ b/backend/v2/epochs/repositories.py @@ -0,0 +1,12 @@ + + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.infrastructure.database.models import PendingEpochSnapshot + + +async def get_pending_epoch_snapshot_by_epoch(session: AsyncSession, epoch: int) -> PendingEpochSnapshot | None: + + result = await session.execute(select(PendingEpochSnapshot).filter(PendingEpochSnapshot.epoch == epoch)) + return result.scalars().first() diff --git a/backend/v2/main.py b/backend/v2/main.py new file mode 100644 index 000000000..d9d51cb01 --- /dev/null +++ b/backend/v2/main.py @@ -0,0 +1,26 @@ + + +# Create FastAPI app +from fastapi import FastAPI + +from v2.allocations.socket import AllocateNamespace + +import socketio + + + +fastapi_app = FastAPI() + +@fastapi_app.get("/fastapi-endpoint") +async def fastapi_endpoint(): + return {"message": "This is a FastAPI endpoint."} + + +sio=socketio.AsyncServer(cors_allowed_origins='*',async_mode='asgi') +sio.register_namespace(AllocateNamespace('/')) +sio_asgi_app = socketio.ASGIApp(socketio_server=sio, other_asgi_app=fastapi_app) + +# app.mount("/static", StaticFiles(directory="static"), name="static") +# fastapi_app.mount("/", sio_asgi_app) +fastapi_app.add_route("/socket.io/", route=sio_asgi_app) +fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) \ No newline at end of file diff --git a/backend/v2/projects/__init__.py b/backend/v2/projects/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/projects/contracts.py b/backend/v2/projects/contracts.py new file mode 100644 index 000000000..09f28afe0 --- /dev/null +++ b/backend/v2/projects/contracts.py @@ -0,0 +1,33 @@ + +import logging +from backend.v2.core.contracts import SmartContract + + +class Projects(SmartContract): + async def get_project_addresses(self, epoch: int) -> list[str]: + logging.debug( + f"[Projects contract] Getting project addresses for epoch: {epoch}" + ) + return await self.contract.functions.getProposalAddresses(epoch).call() + + async def get_project_cid(self): + logging.debug("[Projects contract] Getting projects CID") + return await self.contract.functions.cid().call() + + +PROJECTS_ABI = [ + { + "inputs": [{"internalType": "uint256", "name": "_epoch", "type": "uint256"}], + "name": "getProposalAddresses", + "outputs": [{"internalType": "address[]", "name": "", "type": "address[]"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "cid", + "outputs": [{"internalType": "string", "name": "", "type": "string"}], + "stateMutability": "view", + "type": "function", + }, +] diff --git a/backend/v2/projects/depdendencies.py b/backend/v2/projects/depdendencies.py new file mode 100644 index 000000000..a59bf553c --- /dev/null +++ b/backend/v2/projects/depdendencies.py @@ -0,0 +1,9 @@ +from web3 import AsyncWeb3 +from .contracts import Projects, PROJECTS_ABI + + +# TODO: cache +def get_projects(w3: AsyncWeb3, projects_contract_address: str) -> Projects: + + # projects.init_web3(w3, app.config["PROJECTS_CONTRACT_ADDRESS"]) + return Projects(w3, PROJECTS_ABI, projects_contract_address) diff --git a/backend/v2/projects/services.py b/backend/v2/projects/services.py new file mode 100644 index 000000000..ae9984aef --- /dev/null +++ b/backend/v2/projects/services.py @@ -0,0 +1,123 @@ + + + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.v2.allocations.repositories import sum_allocations_by_epoch +from backend.v2.epochs.repositories import get_pending_epoch_snapshot_by_epoch +from backend.v2.projects.contracts import Projects + + + +async def get_projects_allocation_threshold( + # Dependencies + session: AsyncSession, + projects: Projects, + # Arguments + epoch_number: int, + project_count_multiplier: int = 1, +) -> int: + + # PROJECTS_COUNT_MULTIPLIER = 1 # TODO: from settings? + + total_allocated = await sum_allocations_by_epoch(session, epoch_number) + project_addresses = await projects.get_project_addresses(epoch_number) + + return _calculate_threshold(total_allocated, len(project_addresses), project_count_multiplier) + + +def _calculate_threshold( + total_allocated: int, + projects_count: int, + project_count_multiplier: int, +) -> int: + return ( + int(total_allocated / (projects_count * project_count_multiplier)) + if projects_count + else 0 + ) + + + +async def get_estimated_project_rewards( + # Dependencies + session: AsyncSession, + projects: Projects, + + # Arguments + epoch_number: int, +) -> int: + pass + + all_projects = await projects.get_project_addresses(epoch_number) + + +async def get_estimated_project_matched_rewards_pending( + # Dependencies + session: AsyncSession, + projects: Projects, + + # Arguments + epoch_number: int, +) -> int: + +# pending_snapshot = await get_pending_epoch_snapshot_by_epoch(session, epoch_number) + +# patrons_rewards = + +# def get_patrons_rewards(self, context: Context) -> int: +# epoch = context.epoch_details +# patrons = database.patrons.get_all_patrons_at_timestamp( +# epoch.finalized_timestamp.datetime() +# ) +# return database.budgets.get_sum_by_users_addresses_and_epoch( +# patrons, epoch.epoch_num +# ) + + +# def get_matched_rewards() +# patrons_mode: UserPatronMode + +# def get_matched_rewards(self, context: Context) -> int: +# pending_snapshot = database.pending_epoch_snapshot.get_by_epoch( +# context.epoch_details.epoch_num +# ) +# patrons_rewards = self.patrons_mode.get_patrons_rewards(context) +# matched_rewards_settings = context.epoch_settings.octant_rewards.matched_rewards + +# return matched_rewards_settings.calculate_matched_rewards( +# MatchedRewardsPayload( +# total_rewards=int(pending_snapshot.total_rewards), +# vanilla_individual_rewards=int( +# pending_snapshot.vanilla_individual_rewards +# ), +# patrons_rewards=patrons_rewards, +# staking_proceeds=int(pending_snapshot.eth_proceeds), +# locked_ratio=Decimal(pending_snapshot.locked_ratio), +# ire_percent=context.epoch_settings.octant_rewards.total_and_vanilla_individual_rewards.IRE_PERCENT, +# tr_percent=context.epoch_settings.octant_rewards.total_and_vanilla_individual_rewards.TR_PERCENT, +# ) +# ) + + +# project_rewards = get_estimated_project_rewards().rewards + +# def get_project_rewards(self, context: Context) -> ProjectRewardsResult: +# project_settings = context.epoch_settings.project # .rewards CappedQuadraticFundingProjectRewards +# # all_projects = context.projects_details.projects +# matched_rewards = self.octant_rewards.get_matched_rewards(context) +# allocations = database.allocations.get_all_with_uqs( +# context.epoch_details.epoch_num +# ) + +# allocations_payload = AllocationsPayload( +# before_allocations=allocations, user_new_allocations=[] +# ) +# projects_rewards = get_projects_rewards( +# project_settings, +# allocations_payload, +# all_projects, +# matched_rewards, +# ) + +# return projects_rewards From 591ef920e7033ee36149ef9e43064e7947360790 Mon Sep 17 00:00:00 2001 From: adam-gf Date: Tue, 27 Aug 2024 17:54:14 +0200 Subject: [PATCH 3/6] Adds more migrated code. Allocation and rewards look ok --- backend/app/__init__.py | 2 +- backend/app/extensions.py | 7 +- backend/app/infrastructure/events.py | 69 ++-- backend/app/settings.py | 2 +- backend/poetry.lock | 160 ++++++++- backend/pyproject.toml | 8 + backend/socket_client.py | 85 +++++ backend/startup.py | 7 +- backend/v2/allocations/models.py | 38 +++ backend/v2/allocations/repositories.py | 265 ++++++++++++++- backend/v2/allocations/services.py | 315 ++++++++++++++++++ backend/v2/allocations/socket.py | 254 ++++++++++---- backend/v2/core/contracts.py | 4 +- backend/v2/core/dependencies.py | 45 ++- backend/v2/crypto/__init__.py | 0 backend/v2/crypto/contracts.py | 40 +++ backend/v2/crypto/signatures.py | 63 ++++ backend/v2/epoch_snapshots/__init__.py | 0 backend/v2/epoch_snapshots/repositories.py | 12 + backend/v2/epochs/contracts.py | 13 +- backend/v2/epochs/dependencies.py | 34 +- backend/v2/epochs/repositories.py | 12 - backend/v2/epochs/subgraphs.py | 136 ++++++++ backend/v2/gitcoin_passport/__init__.py | 0 backend/v2/gitcoin_passport/repositories.py | 19 ++ backend/v2/gitcoin_passport/services.py | 25 ++ backend/v2/main.py | 19 +- backend/v2/project_rewards/__init__.py | 0 .../v2/project_rewards/capped_quadriatic.py | 138 ++++++++ backend/v2/projects/contracts.py | 12 +- backend/v2/projects/depdendencies.py | 21 +- backend/v2/projects/services.py | 171 +++++----- backend/v2/uniqueness_quotients/__init__.py | 0 .../v2/uniqueness_quotients/repositories.py | 45 +++ backend/v2/uniqueness_quotients/services.py | 60 ++++ backend/v2/user_patron_mode/__init__.py | 0 backend/v2/user_patron_mode/repositories.py | 116 +++++++ backend/v2/users/__init__.py | 0 backend/v2/users/repositories.py | 11 + 39 files changed, 1943 insertions(+), 265 deletions(-) create mode 100644 backend/socket_client.py create mode 100644 backend/v2/allocations/models.py create mode 100644 backend/v2/allocations/services.py create mode 100644 backend/v2/crypto/__init__.py create mode 100644 backend/v2/crypto/contracts.py create mode 100644 backend/v2/crypto/signatures.py create mode 100644 backend/v2/epoch_snapshots/__init__.py create mode 100644 backend/v2/epoch_snapshots/repositories.py delete mode 100644 backend/v2/epochs/repositories.py create mode 100644 backend/v2/epochs/subgraphs.py create mode 100644 backend/v2/gitcoin_passport/__init__.py create mode 100644 backend/v2/gitcoin_passport/repositories.py create mode 100644 backend/v2/gitcoin_passport/services.py create mode 100644 backend/v2/project_rewards/__init__.py create mode 100644 backend/v2/project_rewards/capped_quadriatic.py create mode 100644 backend/v2/uniqueness_quotients/__init__.py create mode 100644 backend/v2/uniqueness_quotients/repositories.py create mode 100644 backend/v2/uniqueness_quotients/services.py create mode 100644 backend/v2/user_patron_mode/__init__.py create mode 100644 backend/v2/user_patron_mode/repositories.py create mode 100644 backend/v2/users/__init__.py create mode 100644 backend/v2/users/repositories.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 1608020af..7d01ad42e 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -47,7 +47,7 @@ def register_extensions(app): cors.init_app(app) db.init_app(app) migrate.init_app(app, db) - socketio.init_app(app) + # socketio.init_app(app) cache.init_app(app) init_scheduler(app) init_logger(app) diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 40cf6528f..78e0dc2f6 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -1,4 +1,3 @@ -import socketio from flask_apscheduler import APScheduler from flask_cors import CORS from flask_migrate import Migrate @@ -26,11 +25,11 @@ ) # from flask import current_app as app -socketio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", logger=True) +# socketio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", logger=True) - # async_mode="asgi", cors_allowed_origins="*", client_manager=mgr +# async_mode="asgi", cors_allowed_origins="*", client_manager=mgr # ) -# socketio = SocketIO(cors_allowed_origins="*") +socketio = SocketIO(cors_allowed_origins="*") db = SQLAlchemy() migrate = Migrate() cors = CORS() diff --git a/backend/app/infrastructure/events.py b/backend/app/infrastructure/events.py index 87af413de..78cc07ded 100644 --- a/backend/app/infrastructure/events.py +++ b/backend/app/infrastructure/events.py @@ -1,8 +1,8 @@ import json from typing import List -from flask import current_app -# from flask_socketio import emit +from flask import current_app as app +from flask_socketio import emit from app.engine.projects.rewards import ProjectRewardDTO from app.exceptions import OctantException @@ -17,42 +17,32 @@ @socketio.on("connect") -async def handle_connect(sid: str, environ: dict): +def handle_connect(): + app.logger.debug("Client connected") - print("Type of sid", type(sid)) - print("Type of environ", type(environ)) + if epochs.get_pending_epoch() is not None: + threshold = get_allocation_threshold() + emit("threshold", {"threshold": str(threshold)}) - # socketio.logger.debug("Client connected") - # app_instance = current_app._get_current_object() - with current_app.app_context(): + project_rewards = get_estimated_project_rewards().rewards + emit("project_rewards", _serialize_project_rewards(project_rewards)) - current_app.logger.debug("Cl/ient connected") - - print("Epochs are here") - - await socketio.emit("epoch", {"epoch": "fuckup"}) - - if epochs.get_pending_epoch() is not None: - threshold = get_allocation_threshold() - await socketio.emit("threshold", {"threshold": str(threshold)}) - - project_rewards = get_estimated_project_rewards().rewards - await socketio.emit("project_rewards", _serialize_project_rewards(project_rewards)) + for project in project_rewards: + donors = controller.get_all_donations_by_project(project.address) + emit( + "project_donors", + {"project": project.address, "donors": _serialize_donors(donors)}, + ) @socketio.on("disconnect") -async def handle_disconnect(sid): - socketio.logger.debug("Client disconnected") +def handle_disconnect(): + app.logger.debug("Client disconnected") @socketio.on("allocate") -async def handle_allocate(sid, msg): - - print("message", msg) +def handle_allocate(msg): msg = json.loads(msg) - - print("MEssage", msg) - is_manually_edited = msg["isManuallyEdited"] if "isManuallyEdited" in msg else None user_address = msg["userAddress"] app.logger.info(f"User allocation payload: {msg}") @@ -61,7 +51,7 @@ async def handle_allocate(sid, msg): msg, is_manually_edited=is_manually_edited, ) - socketio.logger.info(f"User: {user_address} allocated successfully") + app.logger.info(f"User: {user_address} allocated successfully") threshold = get_allocation_threshold() emit("threshold", {"threshold": str(threshold)}, broadcast=True) @@ -81,21 +71,16 @@ async def handle_allocate(sid, msg): ) -# @socketio.on("project_donors") -# def handle_project_donors(project_address: str): -# print("Project donors") -# emit( -# "project_donors", -# {"project": project_address, "donors": []}, -# ) -# donors = controller.get_all_donations_by_project(project_address) -# emit( -# "project_donors", -# {"project": project_address, "donors": _serialize_donors(donors)}, -# ) +@socketio.on("project_donors") +def handle_project_donors(project_address: str): + donors = controller.get_all_donations_by_project(project_address) + emit( + "project_donors", + {"project": project_address, "donors": _serialize_donors(donors)}, + ) -# @socketio. +@socketio.on_error_default def default_error_handler(e): ExceptionHandler.print_stacktrace(e) if isinstance(e, OctantException): diff --git a/backend/app/settings.py b/backend/app/settings.py index 75c37cb38..d60eacf8d 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -104,7 +104,7 @@ class DevConfig(Config): ENV = "dev" DEBUG = True - LOG_LVL = os.getenv("OCTANT_LOG_LEVEL", "DEBUG") + LOG_LVL = os.getenv("OCTANT_LOG_LEVEL", "INFO") DB_NAME = "dev.db" CHAIN_ID = int(os.getenv("CHAIN_ID", 1337)) # Put the db file in project root diff --git a/backend/poetry.lock b/backend/poetry.lock index 306715b6b..3d379525a 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -109,6 +109,24 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "aiosqlite" +version = "0.20.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"}, + {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] + [[package]] name = "alembic" version = "1.13.1" @@ -1664,6 +1682,20 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -2022,6 +2054,52 @@ url = "https://github.com/stakewise/multiproof.git" reference = "v0.1.2" resolved_reference = "e1f3633a10cb5929cc08d4f261effd170976e7b9" +[[package]] +name = "mypy" +version = "1.11.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -2499,6 +2577,26 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.4.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.4.0-py3-none-any.whl", hash = "sha256:bb6849dc067f1687574c12a639e231f3a6feeed0a12d710c1382045c5db1c315"}, + {file = "pydantic_settings-2.4.0.tar.gz", hash = "sha256:ed81c3a0f46392b4d7c0a565c05884e6e54b3456e6f0fe4d8814981172dc9a88"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" + +[package.extras] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pyflakes" version = "3.1.0" @@ -3017,6 +3115,33 @@ docs = ["sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx-rtd-theme rust-backend = ["rusty-rlp (>=0.2.1)"] test = ["hypothesis (==5.19.0)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] +[[package]] +name = "ruff" +version = "0.6.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, + {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, + {file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"}, + {file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"}, + {file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"}, + {file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"}, + {file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"}, +] + [[package]] name = "sentry-sdk" version = "2.6.0" @@ -3306,24 +3431,43 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "uvicorn" +version = "0.30.6" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + [[package]] name = "web3" -version = "6.19.0" +version = "6.20.3" description = "web3.py" optional = false python-versions = ">=3.7.2" files = [ - {file = "web3-6.19.0-py3-none-any.whl", hash = "sha256:fb39683d6aa7586ce0ab0be4be392f8acb62c2503958079d61b59f2a0b883718"}, - {file = "web3-6.19.0.tar.gz", hash = "sha256:d27fbd4ac5aa70d0e0c516bd3e3b802fbe74bc159b407c34052d9301b400f757"}, + {file = "web3-6.20.3-py3-none-any.whl", hash = "sha256:529fbb33f2476ce8185f7a2ed7e2e07c4c28621b0e89b845fbfdcaea9571286d"}, + {file = "web3-6.20.3.tar.gz", hash = "sha256:c69dbf1a61ace172741d06990e60afc7f55f303eac087e7235f382df3047d017"}, ] [package.dependencies] aiohttp = ">=3.7.4.post0" +ckzg = "<2" eth-abi = ">=4.0.0" eth-account = ">=0.8.0,<0.13" eth-hash = {version = ">=0.5.1", extras = ["pycryptodome"]} -eth-typing = ">=3.0.0,<4.2.0 || >4.2.0" -eth-utils = ">=2.1.0" +eth-typing = ">=3.0.0,<4.2.0 || >4.2.0,<5.0.0" +eth-utils = ">=2.1.0,<5" hexbytes = ">=0.1.0,<0.4.0" jsonschema = ">=4.0.0" lru-dict = ">=1.1.6,<1.3.0" @@ -3335,10 +3479,10 @@ typing-extensions = ">=4.0.1" websockets = ">=10.0.0" [package.extras] -dev = ["build (>=0.9.0)", "bumpversion", "eth-tester[py-evm] (>=0.11.0b1,<0.12.0b1)", "eth-tester[py-evm] (>=0.9.0b1,<0.10.0b1)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "importlib-metadata (<5.0)", "ipfshttpclient (==0.8.0a2)", "pre-commit (>=2.21.0)", "py-geth (>=3.14.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.21.2,<0.23)", "pytest-mock (>=1.10)", "pytest-watch (>=4.2)", "pytest-xdist (>=1.29)", "setuptools (>=38.6.0)", "sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=3.18.0)", "tqdm (>4.32)", "twine (>=1.13)", "when-changed (>=0.3.0)"] +dev = ["build (>=0.9.0)", "bumpversion", "eth-tester[py-evm] (>=0.11.0b1,<0.12.0b1)", "eth-tester[py-evm] (>=0.9.0b1,<0.10.0b1)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "importlib-metadata (<5.0)", "ipfshttpclient (==0.8.0a2)", "pre-commit (>=2.21.0)", "py-geth (>=3.14.0,<4)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.21.2,<0.23)", "pytest-mock (>=1.10)", "pytest-watch (>=4.2)", "pytest-xdist (>=1.29)", "setuptools (>=38.6.0)", "sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=3.18.0)", "tqdm (>4.32)", "twine (>=1.13)", "when-changed (>=0.3.0)"] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] ipfs = ["ipfshttpclient (==0.8.0a2)"] -tester = ["eth-tester[py-evm] (>=0.11.0b1,<0.12.0b1)", "eth-tester[py-evm] (>=0.9.0b1,<0.10.0b1)", "py-geth (>=3.14.0)"] +tester = ["eth-tester[py-evm] (>=0.11.0b1,<0.12.0b1)", "eth-tester[py-evm] (>=0.9.0b1,<0.10.0b1)", "py-geth (>=3.14.0,<4)"] [[package]] name = "websockets" @@ -3558,4 +3702,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "07a0348e2a44a40f17ba9605b8de32af30fd335a64b6fd9559bce5a3cd821c76" +content-hash = "16f114d4cb7ff5c5e93c64c3feb4a4a27ca7e7998c556380dbec82b6d2668d77" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 12df28a42..b57281164 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,10 @@ gmpy2 = "^2.1.5" sentry-sdk = {extras = ["flask"], version = "^2.5.1"} redis = "^5.0.7" fastapi = "^0.112.0" +mypy = "^1.11.2" +isort = "^5.13.2" +pydantic-settings = "^2.4.0" +uvicorn = "^0.30.6" [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" @@ -44,6 +48,10 @@ pyright = "^1.1.366" pylookup = "^0.2.2" importmagic = "^0.1.7" epc = "^0.0.5" +isort = "^5.13.2" +mypy = "^1.11.2" +ruff = "^0.6.2" +aiosqlite = "^0.20.0" [tool.poetry.group.prod] optional = true diff --git a/backend/socket_client.py b/backend/socket_client.py new file mode 100644 index 000000000..b49fff776 --- /dev/null +++ b/backend/socket_client.py @@ -0,0 +1,85 @@ +import asyncio +import socketio + +# Create a Socket.IO client +sio = socketio.AsyncClient(logger=True, engineio_logger=True) + + +# Define event handlers +@sio.event +async def connect(): + print(">>>Connected to the server") + + +@sio.event +async def connect_error(data): + print(">>>The connection failed with error:", data) + + +@sio.event +async def disconnect(): + print(">>>I'm disconnected!") + + +# A handler for any event with a wildcard (not all implementations of Socket.IO support this feature directly) +@sio.on("*") +async def catch_all(event, data): + print(f">>>Received an event of type '{event}' with data:", data) + + +@sio.event +async def epoch(data): + print(f">>>Epoch received: {data}") + + +@sio.event +async def project_rewards(data): + print(f"Message received: {data}") + + +@sio.event +async def threshold(data): + print(f"Custom event received: {data}") + + +# Connect to the server +async def main(): + print("Connecting to the server...") + await sio.connect("http://localhost:8000/", wait_timeout=10) + print("Connected. Waiting for events...") + # This line will keep the client running and listening for events + + # Emit events + + # Emit a custom event + data = { + "userAddress": "0xb429d71F676f6e804010D8B699EefbF1ed050420", + "payload": { + "allocations": [ + { + "proposalAddress": "0x1c01595f9534E33d411035AE99a4317faeC4f6Fe", + "amount": 100, + }, + { + "proposalAddress": "0x6e8873085530406995170Da467010565968C7C62", + "amount": 200, + }, + ], + "nonce": 0, + "signature": "0x03c0e67cdc612bf1c0a690346805c5f461fbc0a8fe3041b4849c9ddbc939553a53997dfb6578200192e071618d9f054ae68513f134206149acf70ff04cea02931c", + }, + "isManuallyEdited": False, + } + await sio.emit("allocate", data) + + await sio.wait() + + +# Emit events +async def emit_event(event_name, data): + await sio.emit(event_name, data) + + +# Run the client +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/startup.py b/backend/startup.py index f6a4b8159..5e3a7b9dc 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -27,6 +27,7 @@ # Create Flask app flask_app = create_app() + @flask_app.teardown_request def teardown_session(*args, **kwargs): db.session.remove() @@ -55,12 +56,12 @@ async def dispatch(self, request: Request, call_next): # If path exists, proceed with the request return await call_next(request) # If path does not exist, modify the request to forward to the Flask app - if path.startswith('/flask'): + if path.startswith("/flask"): return await call_next(request) - request.scope['path'] = '/flask' + path # Adjust the path as needed + request.scope["path"] = "/flask" + path # Adjust the path as needed response = await call_next(request) return response - + fastapi_app.add_middleware(PathCheckMiddleware) diff --git a/backend/v2/allocations/models.py b/backend/v2/allocations/models.py new file mode 100644 index 000000000..8f1a4a9fe --- /dev/null +++ b/backend/v2/allocations/models.py @@ -0,0 +1,38 @@ +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class AllocationWithUserUQScore(BaseModel): + model_config = ConfigDict(frozen=True) + + project_address: str + amount: int + user_address: str + user_uq_score: Decimal + + +class AllocationRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + project_address: str + amount: int + + +class UserAllocationRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + user_address: str + allocations: list[AllocationRequest] + nonce: int + signature: str + + is_manually_edited: bool + + +class ProjectDonation(BaseModel): + model_config = ConfigDict(frozen=True) + + amount: int + donor_address: str # user address + project_address: str diff --git a/backend/v2/allocations/repositories.py b/backend/v2/allocations/repositories.py index 0a0bb84dc..48d2c6200 100644 --- a/backend/v2/allocations/repositories.py +++ b/backend/v2/allocations/repositories.py @@ -1,18 +1,265 @@ +from datetime import datetime +from decimal import Decimal - -from sqlalchemy import func, select +from app.infrastructure.database.models import Allocation +from app.infrastructure.database.models import AllocationRequest as AllocationRequestDB +from app.infrastructure.database.models import UniquenessQuotient, User +from eth_utils import to_checksum_address +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload +from sqlalchemy.sql.functions import coalesce +from v2.users.repositories import get_user_by_address - -from backend.app.infrastructure.database.models import Allocation +from .models import AllocationWithUserUQScore, ProjectDonation, UserAllocationRequest -async def sum_allocations_by_epoch(session: AsyncSession, epoch: int) -> int: - """Get the sum of all allocations for a given epoch. We only consider the allocations that have not been deleted. - """ +async def sum_allocations_by_epoch(session: AsyncSession, epoch_number: int) -> int: + """Get the sum of all allocations for a given epoch. We only consider the allocations that have not been deleted.""" result = await session.execute( - select(func.sum(Allocation.amount)).filter(Allocation.epoch == epoch).filter(Allocation.deleted_at.is_(None)) + select(coalesce(func.sum(Allocation.amount), 0)) + .filter(Allocation.epoch == epoch_number) + .filter(Allocation.deleted_at.is_(None)) ) count = result.scalar() - return count \ No newline at end of file + + if count is None: + return 0 + + return count + + +async def get_allocations_with_user_uqs( + session: AsyncSession, epoch_number: int +) -> list[AllocationWithUserUQScore]: + """Get all allocations for a given epoch, including the uniqueness quotients of the users.""" + + # result = await session.execute( + # select(Allocation) + # .filter(Allocation.epoch == epoch) + # .filter(Allocation.deleted_at.is_(None)) + # .options(joinedload(Allocation.user).joinedload(User.uniqueness_quotients)) + # ) + # allocations = result.scalars().all() + + # return [ + # AllocationWithUserUQScore( + # project_address=a.project_address, + # amount=int(a.amount), + # user_address=a.user.address, + # user_uq_score=next( + # ( + # uq.validated_score + # for uq in a.user.uniqueness_quotients + # if uq.epoch == epoch + # ), + # None, + # ), + # ) + # for a in allocations + # ] + + result = await session.execute( + select( + Allocation.project_address, + Allocation.amount, + User.address.label("user_address"), + UniquenessQuotient.score, + ) + .join(User, Allocation.user_id == User.id) + .join(UniquenessQuotient, UniquenessQuotient.user_id == User.id) + .filter(Allocation.epoch == epoch_number) + .filter(Allocation.deleted_at.is_(None)) + .filter(UniquenessQuotient.epoch == epoch_number) + ) + + # result = await session.execute( + # select( + # Allocation.id.label('allocation_id'), + # Allocation.amount.label('allocation_amount'), + # User.id.label('user_id'), + # User.name.label('user_name'), + # UniquenessQuotient.id.label('uq_id'), + # UniquenessQuotient.score.label('uq_score') + # ) + # .join(User, Allocation.user_id == User.id) + # .join(UniquenessQuotient, UniquenessQuotient.user_id == User.id) + # .filter(Allocation.epoch == epoch_number) + # .filter(Allocation.deleted_at.is_(None)) + # .filter(UniquenessQuotient.epoch == epoch_number) + # ) + + rows = result.all() + + return [ + AllocationWithUserUQScore( + project_address=project_address, + amount=amount, + user_address=user_address, + user_uq_score=Decimal(uq_score), + ) + for project_address, amount, user_address, uq_score in rows + ] + + +# allocations = database.allocations.get_all_with_uqs( +# context.epoch_details.epoch_num +# ) + +# def get_all_allocations_with_uqs(epoch: int) -> List[AllocationDTO]: +# allocations = ( +# Allocation.query.filter_by(epoch=epoch) +# .filter(Allocation.deleted_at.is_(None)) +# .options(joinedload(Allocation.user).joinedload(User.uniqueness_quotients)) +# .all() +# ) + +# return [ +# AllocationDTO( +# amount=int(a.amount), +# project_address=a.project_address, +# user_address=a.user.address, +# uq_score=next( +# ( +# uq.validated_score +# for uq in a.user.uniqueness_quotients +# if uq.epoch == epoch +# ), +# None, +# ), +# ) +# for a in allocations +# ] + + +async def soft_delete_user_allocations_by_epoch( + session: AsyncSession, + user_address: str, + epoch_number: int, +) -> None: + """Soft delete all user allocations for a given epoch.""" + + # Find all the allocations for the user and epoch that have not been deleted + user = await get_user_by_address(session, user_address) + + if user is None: + return None + + now = datetime.utcnow() + + # Perform a batch update to soft delete the allocations + await session.execute( + update(Allocation) + .where( + Allocation.epoch == epoch_number, + Allocation.user_id == user.id, + Allocation.deleted_at.is_(None), + ) + .values(deleted_at=now) + ) + + +async def store_allocation_request( + session: AsyncSession, + user_address: str, + epoch_number: int, + request: UserAllocationRequest, + leverage: float, +) -> None: + """Store an allocation request in the database.""" + + user = await get_user_by_address(session, user_address) + if user is None: + return None + + new_allocations = [ + Allocation( + epoch=epoch_number, + user_id=user.id, + nonce=request.nonce, + project_address=to_checksum_address(a.project_address), + amount=str(a.amount), + ) + for a in request.allocations + ] + + allocation_request = AllocationRequestDB( + user_id=user.id, + epoch=epoch_number, + nonce=request.nonce, + signature=request.signature, + is_manually_edited=request.is_manually_edited, + leverage=leverage, + ) + + session.add(allocation_request) + session.add_all(new_allocations) + + +async def get_last_allocation_request_nonce( + session: AsyncSession, + user_address: str, +) -> int | None: + """Get the last nonce of the allocation requests for a user.""" + + user = await get_user_by_address(session, user_address) + if user is None: + return None + + result = await session.execute( + select(func.max(AllocationRequestDB.nonce)).filter( + AllocationRequestDB.user_id == user.id + ) + ) + + return result.scalar() + + +async def get_donations_by_project( + session: AsyncSession, + project_address: str, + epoch_number: int, +) -> list[ProjectDonation]: + result = await session.execute( + select(Allocation) + .filter(Allocation.project_address == project_address) + .filter(Allocation.epoch == epoch_number) + .filter(Allocation.deleted_at.is_(None)) + .options(joinedload(Allocation.user)) + ) + + allocations = result.all() + + return [ + ProjectDonation( + amount=int(a.amount), + donor_address=a.user.address, + project_address=a.project_address, + ) + for a in allocations + ] + + # query: Query = Allocation.query.filter_by( + # project_address=to_checksum_address(project_address), epoch=epoch + # ).options(joinedload(Allocation.user)) + + # if not with_deleted: + # query = query.filter(Allocation.deleted_at.is_(None)) + + # return query.all() + + # def get_allocations_by_project( + # self, context: Context, project_address: str + # ) -> List[ProjectDonationDTO]: + # allocations = database.allocations.get_all_by_project_addr_and_epoch( + # project_address, context.epoch_details.epoch_num + # ) + + # return [ + # ProjectDonationDTO( + # donor=a.user.address, amount=int(a.amount), project=project_address + # ) + # for a in allocations + # if int(a.amount) != 0 + # ] diff --git a/backend/v2/allocations/services.py b/backend/v2/allocations/services.py new file mode 100644 index 000000000..5fdea2ea4 --- /dev/null +++ b/backend/v2/allocations/services.py @@ -0,0 +1,315 @@ +from decimal import Decimal + +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.project_rewards.capped_quadriatic import ( + capped_quadriatic_funding, + cqf_calculate_individual_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.users.repositories import get_user_by_address +from web3 import AsyncWeb3 + +from .models 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, +) + + +async def allocate( + # Component dependencies + session: AsyncSession, + projects_contracts: ProjectsContracts, + epochs_subgraph: EpochsSubgraph, + # 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, + epoch_number=epoch_number, + payload=request, + ) + await verify_signature( + w3=projects_contracts.w3, + chain_id=chain_id, + user_address=request.user_address, + payload=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, + epoch_number=epoch_number, + uq_score_threshold=uq_score_threshold, + max_uq_score=max_uq_score, + low_uq_score=low_uq_score, + ) + + # Calculate leverage by simulating the allocation + new_allocations = [ + AllocationWithUserUQScore( + project_address=a.project_address, + amount=a.amount, + user_address=request.user_address, + user_uq_score=user_uq_score, + ) + for a in request.allocations + ] + leverage = await calculate_leverage( + session=session, + projects=projects_contracts, + epochs_subgraph=epochs_subgraph, + epoch_number=epoch_number, + user_address=request.user_address, + new_allocations=new_allocations, + ) + + await soft_delete_user_allocations_by_epoch( + session, + user_address=request.user_address, + epoch_number=epoch_number, + ) + + # Get user and update allocation nonce + user = await get_user_by_address(session, request.user_address) + if user is None: + raise exceptions.UserNotFound(request.user_address) + + user.allocation_nonce = request.nonce + + await store_allocation_request( + session, + request.user_address, + epoch_number, + request, + leverage=leverage, + ) + + # Commit the transaction + await session.commit() + + return request.user_address + + +async def calculate_leverage( + # Component dependencies + session: AsyncSession, + projects: ProjectsContracts, + epochs_subgraph: EpochsSubgraph, + # Arguments + epoch_number: int, + user_address: str, + new_allocations: list[AllocationWithUserUQScore], +) -> float: + """ + Calculate leverage of the allocation made by the user. + """ + + 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, + ) + + # 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, + 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 6b84064cb..508d1a086 100644 --- a/backend/v2/allocations/socket.py +++ b/backend/v2/allocations/socket.py @@ -1,40 +1,37 @@ -import json import logging from typing import List -from flask import current_app import socketio -# from flask_socketio import emit - from app.engine.projects.rewards import ProjectRewardDTO from app.exceptions import OctantException + # from app.extensions import socketio, epochs from app.infrastructure.exception_handler import UNEXPECTED_EXCEPTION, ExceptionHandler - from app.modules.dto import ProjectDonationDTO -from app.modules.projects.rewards.controller import get_estimated_project_rewards -from app.modules.user.allocations import controller - +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, get_w3, w3_getter +from v2.epochs.dependencies import epochs_getter, epochs_subgraph_getter, get_epochs +from v2.projects.depdendencies import get_projects, projects_getter +from v2.projects.services import ( + get_estimated_project_rewards, + get_projects_allocation_threshold, +) -from backend.v2.core.dependencies import get_w3 -from backend.v2.epochs.contracts import Epochs -from backend.v2.epochs.dependencies import get_epochs -from backend.v2.projects.contracts import Projects - -from backend.v2.projects.depdendencies import get_projects -from backend.v2.projects.services import get_projects_allocation_threshold +from .models import AllocationRequest, UserAllocationRequest class AllocateNamespace(socketio.AsyncNamespace): - def __init__(self, namespace: str): super().__init__(namespace=namespace) - self.w3 = get_w3() - self.settings = get_settings() - self.epochs = get_epochs(self.w3, self.settings.epochs_contract_address) - self.projects = get_projects(self.w3, self.settings.projects_contract_address) - + # 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() + async def on_connect(self, sid: str, environ: dict): """ Handle client connection @@ -43,9 +40,6 @@ async def on_connect(self, sid: str, environ: dict): print("Type of sid", type(sid)) print("Type of environ", type(environ)) - # socketio.logger.debug("Client connected") - # app_instance = current_app._get_current_object() - logging.debug("Client connected") print("Epochs are here") @@ -53,72 +47,200 @@ async def on_connect(self, sid: str, environ: dict): await self.emit("epoch", {"epoch": "fuckup"}) # We send the data only in PENDING state - pending_epoch_number = await self.epochs.get_pending_epoch() - + pending_epoch_number = await self.epochs_contracts.get_pending_epoch() + + epoch_end = await self.epochs_contracts.get_current_epoch_end() + + print("epocg_end", epoch_end) + print("Pending epoch =", pending_epoch_number) + # We do not handle requests outside of pending epoch state - if pending_epoch_number is None: - return + # if pending_epoch_number is None: + # return + pending_epoch_number = 124 - threshold = await get_projects_allocation_threshold( - session=self.session, # TODO: - projects=self.projects, - epoch_number=pending_epoch_number - ) + 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)}) + await self.emit("threshold", {"threshold": str(threshold)}) - project_rewards = get_estimated_project_rewards().rewards - await self.emit("project_rewards", _serialize_project_rewards(project_rewards)) + project_rewards = await get_estimated_project_rewards( + session=session, + projects=self.projects_contracts, + epochs_subgraph=self.epochs_subgraph, + 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() + ] - async def on_disconnect(self, sid): + # project_rewards = get_estimated_project_rewards().rewards + await self.emit("project_rewards", rewards) + async def on_disconnect(self, sid): logging.debug("Client disconnected") + async def on_allocate(self, sid: str, data: dict): + """ + Handle allocation request + """ - async def on_allocate(self, sid: str, environ: dict) -> None: + # # We do not handle requests outside of pending epoch state (Allocation Window) + # pending_epoch_number = await self.epochs_contracts.get_pending_epoch() + # if pending_epoch_number is None: + # return - - print("message", msg) - msg = json.loads(msg) + print("message", data, type(data)) - print("MEssage", msg) + request = from_dict(data) + pending_epoch_number = 124 - is_manually_edited = msg["isManuallyEdited"] if "isManuallyEdited" in msg else None - user_address = msg["userAddress"] - logging.info(f"User allocation payload: {msg}") - controller.allocate( - user_address, - msg, - is_manually_edited=is_manually_edited, - ) - socketio.logger.info(f"User: {user_address} allocated successfully") + 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, + ) - threshold = get_projects_allocation_threshold() - await self.emit("threshold", {"threshold": str(threshold)}, broadcast=True) + threshold = await get_projects_allocation_threshold( + session=session, + projects=self.projects_contracts, + epoch_number=pending_epoch_number, + ) - project_rewards = get_estimated_project_rewards().rewards - await self.emit( - "project_rewards", - _serialize_project_rewards(project_rewards), - broadcast=True, - ) - for project in project_rewards: - donors = controller.get_all_donations_by_project(project.address) - await self.emit( - "project_donors", - {"project": project.address, "donors": _serialize_donors(donors)}, - broadcast=True, + 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, ) + 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, + project_address=project_address, + epoch_number=pending_epoch_number, + ) + + await self.emit( + "project_donors", + {"project": project_address, "donors": donations}, + ) + + # msg = json.loads(msg) + + # print("MEssage", msg) + + # is_manually_edited = data.get("isManuallyEdited", None) + # user_address = data["userAddress"] + # # is_manually_edited = ( + # # msg["isManuallyEdited"] if "isManuallyEdited" in msg else None + # # ) + # logging.info(f"User allocation payload: {msg}") + + # controller.allocate( + # user_address, + # msg, + # is_manually_edited=is_manually_edited, + # ) + # socketio.logger.info(f"User: {user_address} allocated successfully") + + # threshold = get_projects_allocation_threshold() + # await self.emit("threshold", {"threshold": str(threshold)}, broadcast=True) + + # project_rewards = get_estimated_project_rewards().rewards + # await self.emit( + # "project_rewards", + # _serialize_project_rewards(project_rewards), + # broadcast=True, + # ) + # for project in project_rewards: + # donors = controller.get_all_donations_by_project(project.address) + # await self.emit( + # "project_donors", + # {"project": project.address, "donors": _serialize_donors(donors)}, + # broadcast=True, + # ) + + +def from_dict(data: dict) -> UserAllocationRequest: + """ + Example of data: + { + "userAddress": "0x123", + "payload": { + "allocations": [ + { + "proposalAddress": "0x456", + "amount": 100 + }, + { + "proposalAddress": "0x789", + "amount": 200 + } + ], + "nonce": 1, + "signature": "0xabc" + }, + "isManuallyEdited": False + } + """ + user_address = to_checksum_address(data["userAddress"]) + payload = data["payload"] + allocations = [ + AllocationRequest( + project_address=to_checksum_address(allocation_data["proposalAddress"]), + amount=allocation_data["amount"], + ) + for allocation_data in payload["allocations"] + ] + nonce = int(payload["nonce"]) + 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, + ) + # fmt: on + # def state_context(epoch_state: EpochState) -> Context: # epoch_num = get_epoch_number(epoch_state) # return build_context(epoch_num, epoch_state, with_block_range) - # @socketio.on("project_donors") # def handle_project_donors(project_address: str): # print("Project donors") diff --git a/backend/v2/core/contracts.py b/backend/v2/core/contracts.py index 2ffe188ea..e73c01d13 100644 --- a/backend/v2/core/contracts.py +++ b/backend/v2/core/contracts.py @@ -1,11 +1,11 @@ - +from eth_typing import ChecksumAddress from web3 import AsyncWeb3 from web3.contract import AsyncContract from web3.types import ABI class SmartContract: - def __init__(self, w3: AsyncWeb3, abi: ABI, address: str) -> None: + def __init__(self, w3: AsyncWeb3, abi: ABI, address: ChecksumAddress) -> None: self.abi = abi self.w3 = w3 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 0a7a0af65..b19f65cda 100644 --- a/backend/v2/core/dependencies.py +++ b/backend/v2/core/dependencies.py @@ -1,14 +1,49 @@ -from web3 import AsyncWeb3 +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 -# TODO: Cache? -def get_w3(web3_provider: str) -> AsyncWeb3: +class Web3ProviderSettings(BaseSettings): + eth_rpc_provider_url: str - w3 = AsyncWeb3() - w3.provider = web3_provider + +# TODO: Cache? +def get_w3(eth_rpc_provider_url: str) -> AsyncWeb3: + w3 = AsyncWeb3(provider=AsyncHTTPProvider(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) + + +class DatabaseSettings(BaseSettings): + sqlalchemy_database_uri: str = Field(validation_alias="db_uri") + # TODO other settings of the database + + +async def create_tables(): + settings = DatabaseSettings() + engine = create_async_engine(settings.sqlalchemy_database_uri) + async with engine.begin() as conn: + await conn.run_sync(BaseModel.metadata.create_all) + + +def get_db_engine(database_uri: str) -> async_sessionmaker[AsyncSession]: + engine = create_async_engine(database_uri) + + return sessionmaker(bind=engine, class_=AsyncSession) + + +def db_getter() -> async_sessionmaker[AsyncSession]: + settings = DatabaseSettings() + return get_db_engine(settings.sqlalchemy_database_uri) diff --git a/backend/v2/crypto/__init__.py b/backend/v2/crypto/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/crypto/contracts.py b/backend/v2/crypto/contracts.py new file mode 100644 index 000000000..101d1584b --- /dev/null +++ b/backend/v2/crypto/contracts.py @@ -0,0 +1,40 @@ +import logging + +from app.constants import EIP1271_MAGIC_VALUE_BYTES +from v2.core.contracts import SmartContract + + +class GnosisSafeContracts(SmartContract): + async def is_valid_signature(self, msg_hash: str, signature: str) -> bool: + logging.info( + f"[Gnosis Safe Contract] checking if a message with hash: {msg_hash} is already signed by {self.contract.address}" + ) + + result = await self.contract.functions.isValidSignature( + msg_hash, signature + ).call() + return result == bytes.fromhex(EIP1271_MAGIC_VALUE_BYTES) + + async def get_message_hash(self, message: bytes) -> str: + return await self.contract.functions.getMessageHash(message).call() + + +GNOSIS_SAFE = [ + { + "inputs": [ + {"internalType": "bytes", "name": "_data", "type": "bytes"}, + {"internalType": "bytes", "name": "_signature", "type": "bytes"}, + ], + "name": "isValidSignature", + "outputs": [{"internalType": "bytes4", "name": "", "type": "bytes4"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes", "name": "message", "type": "bytes"}], + "name": "getMessageHash", + "outputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, +] diff --git a/backend/v2/crypto/signatures.py b/backend/v2/crypto/signatures.py new file mode 100644 index 000000000..85409203d --- /dev/null +++ b/backend/v2/crypto/signatures.py @@ -0,0 +1,63 @@ +from eth_account import Account +from eth_account.messages import SignableMessage, _hash_eip191_message +from eth_keys.exceptions import BadSignature +from eth_utils import to_checksum_address +from v2.crypto.contracts import GNOSIS_SAFE, GnosisSafeContracts +from web3 import AsyncWeb3 +from web3.exceptions import ContractLogicError + + +async def verify_signed_message( + w3: AsyncWeb3, + user_address: str, + encoded_msg: SignableMessage, + signature: str, +) -> bool: + contract = await is_contract(w3, user_address) + if contract: + return await _verify_multisig(w3, user_address, encoded_msg, signature) + + return _verify_eoa(user_address, encoded_msg, signature) + + +async def is_contract(w3: AsyncWeb3, address: str) -> bool: + """ + Check if the given address is a contract. + + Args: + - address (str): Ethereum address to check. + """ + address = to_checksum_address(address) + is_address = w3.is_address(address) + + if not is_address: + raise ValueError(f"{address} is not a valid Ethereum address!") + + code = await w3.eth.get_code(address) + + return code.hex() != "0x" + + +def hash_signable_message(encoded_msg: SignableMessage) -> str: + return "0x" + _hash_eip191_message(encoded_msg).hex() + + +async def _verify_multisig( + w3: AsyncWeb3, user_address: str, encoded_msg: SignableMessage, signature: str +) -> bool: + msg_hash = hash_signable_message(encoded_msg) + try: + gnosis_safe = GnosisSafeContracts(w3=w3, abi=GNOSIS_SAFE, address=user_address) + return await gnosis_safe.is_valid_signature(msg_hash, signature) + except ContractLogicError: + return False + + +def _verify_eoa( + user_address: str, encoded_msg: SignableMessage, signature: str +) -> bool: + try: + recovered_address = Account.recover_message(encoded_msg, signature=signature) + except BadSignature: + return False + return recovered_address == user_address diff --git a/backend/v2/epoch_snapshots/__init__.py b/backend/v2/epoch_snapshots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/epoch_snapshots/repositories.py b/backend/v2/epoch_snapshots/repositories.py new file mode 100644 index 000000000..b1e3117d9 --- /dev/null +++ b/backend/v2/epoch_snapshots/repositories.py @@ -0,0 +1,12 @@ +from app.infrastructure.database.models import PendingEpochSnapshot +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +async def get_pending_epoch_snapshot( + session: AsyncSession, epoch_number: int +) -> PendingEpochSnapshot | None: + result = await session.execute( + select(PendingEpochSnapshot).filter(PendingEpochSnapshot.epoch == epoch_number) + ) + return result.scalar_one_or_none() diff --git a/backend/v2/epochs/contracts.py b/backend/v2/epochs/contracts.py index a4614f7fd..565d353c2 100644 --- a/backend/v2/epochs/contracts.py +++ b/backend/v2/epochs/contracts.py @@ -1,14 +1,11 @@ -from typing import Dict, Literal, Optional, TypedDict - -from web3 import exceptions - import logging +from typing import Dict, Optional -from backend.v2.core.contracts import SmartContract - +from v2.core.contracts import SmartContract +from web3 import exceptions -class Epochs(SmartContract): +class EpochsContracts(SmartContract): async def is_decision_window_open(self) -> bool: logging.debug("[Epochs contract] Checking if decision window is open") return await self.contract.functions.isDecisionWindowOpen().call() @@ -152,4 +149,4 @@ async def start(self) -> int: "stateMutability": "view", "type": "function", }, -] \ No newline at end of file +] diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py index a32f25b81..712f2d9da 100644 --- a/backend/v2/epochs/dependencies.py +++ b/backend/v2/epochs/dependencies.py @@ -1,8 +1,36 @@ +from typing import Callable + +from pydantic_settings import BaseSettings +from v2.core.dependencies import w3_getter from web3 import AsyncWeb3 -from .contracts import Epochs, EPOCHS_ABI + +from .contracts import EPOCHS_ABI, EpochsContracts +from .subgraphs import EpochsSubgraph + + +class EpochsSettings(BaseSettings): + epochs_contract_address: str # TODO: cache -def get_epochs(w3: AsyncWeb3, epochs_contract_address: str) -> Epochs: +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] + + +class EpochsSubgraphSettings(BaseSettings): + subgraph_endpoint: str + + # url = config["SUBGRAPH_ENDPOINT"] + - return Epochs(w3, EPOCHS_ABI, epochs_contract_address) +def epochs_subgraph_getter() -> EpochsSubgraph: + settings = EpochsSubgraphSettings() # type: ignore + return EpochsSubgraph(settings.subgraph_endpoint) diff --git a/backend/v2/epochs/repositories.py b/backend/v2/epochs/repositories.py deleted file mode 100644 index 33aa9178a..000000000 --- a/backend/v2/epochs/repositories.py +++ /dev/null @@ -1,12 +0,0 @@ - - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from backend.app.infrastructure.database.models import PendingEpochSnapshot - - -async def get_pending_epoch_snapshot_by_epoch(session: AsyncSession, epoch: int) -> PendingEpochSnapshot | None: - - result = await session.execute(select(PendingEpochSnapshot).filter(PendingEpochSnapshot.epoch == epoch)) - return result.scalars().first() diff --git a/backend/v2/epochs/subgraphs.py b/backend/v2/epochs/subgraphs.py new file mode 100644 index 000000000..d7f8e6cf7 --- /dev/null +++ b/backend/v2/epochs/subgraphs.py @@ -0,0 +1,136 @@ +import logging +from dataclasses import dataclass +from typing import Callable, Sequence, Type, Union + +import backoff +from app import exceptions +from app.context.epoch.details import EpochDetails +from gql import Client, gql +from gql.transport.aiohttp import AIOHTTPTransport +from gql.transport.exceptions import TransportQueryError + +# def lookup_max_time(): +# return config.SUBGRAPH_RETRY_TIMEOUT_SEC + + +exception_type = TransportQueryError + + +def is_graph_error_permanent(error: TransportQueryError) -> bool: + # TODO: if we differentiate between reasons for the error, + # we can differentiate between transient and permanent ones, + # so we can return True for permanent ones saving + # up to SUBGRAPH_RETRY_TIMEOUT_SEC. + # Look for these prints in logs and find + # "the chain was reorganized while executing the query" line. + logging.debug("going through giveup...") + logging.debug(f"got TransportQueryError.query_id: {error.query_id}") + logging.debug(f"got TransportQueryError.errors: {error.errors}") + logging.debug(f"got TransportQueryError.data: {error.data}") + logging.debug(f"got TransportQueryError.extensions: {error.extensions}") + return False + + +# url = config["SUBGRAPH_ENDPOINT"] + + +@dataclass +class BackoffParams: + exception: Union[Type[Exception], Sequence[Type[Exception]]] + max_time: int + giveup: Callable[[Exception], bool] = lambda e: False + + +class EpochsSubgraph: + def __init__( + self, + url: str, + backoff_params: BackoffParams | None = None, + ): + self.url = url + 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 get_epoch_by_number(self, epoch_number: int) -> EpochDetails: + """Get EpochDetails from the subgraph for a given epoch number.""" + + logging.debug( + f"[Subgraph] Getting epoch properties for epoch number: {epoch_number}" + ) + + # Prepare query and variables + query = gql( + """\ + query GetEpoch($epochNo: Int!) { + epoches(where: {epoch: $epochNo}) { + epoch + fromTs + toTs + duration + 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]}") + + epoch_details = data[0] + + return EpochDetails( + epoch_num=epoch_details["epoch"], + start=epoch_details["fromTs"], + duration=epoch_details["duration"], + decision_window=epoch_details["decisionWindow"], + remaining_sec=0, + ) + + +# def get_epochs(): +# query = gql( +# """ +# query { +# epoches(first: 1000) { +# epoch +# fromTs +# toTs +# } +# _meta { +# block { +# number +# } +# } +# } +# """ +# ) + +# app.logger.debug("[Subgraph] Getting list of all epochs") +# data = gql_factory.build().execute(query) +# return data diff --git a/backend/v2/gitcoin_passport/__init__.py b/backend/v2/gitcoin_passport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/gitcoin_passport/repositories.py b/backend/v2/gitcoin_passport/repositories.py new file mode 100644 index 000000000..272a12990 --- /dev/null +++ b/backend/v2/gitcoin_passport/repositories.py @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..786c3cbe9 --- /dev/null +++ b/backend/v2/gitcoin_passport/services.py @@ -0,0 +1,25 @@ +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/main.py b/backend/v2/main.py index d9d51cb01..4fb429fed 100644 --- a/backend/v2/main.py +++ b/backend/v2/main.py @@ -1,26 +1,25 @@ - - # Create FastAPI app +import socketio from fastapi import FastAPI - from v2.allocations.socket import AllocateNamespace - -import socketio - - +from v2.core.dependencies import create_tables fastapi_app = FastAPI() + @fastapi_app.get("/fastapi-endpoint") async def fastapi_endpoint(): return {"message": "This is a FastAPI endpoint."} -sio=socketio.AsyncServer(cors_allowed_origins='*',async_mode='asgi') -sio.register_namespace(AllocateNamespace('/')) +sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="asgi") +sio.register_namespace(AllocateNamespace("/")) sio_asgi_app = socketio.ASGIApp(socketio_server=sio, other_asgi_app=fastapi_app) # app.mount("/static", StaticFiles(directory="static"), name="static") # fastapi_app.mount("/", sio_asgi_app) fastapi_app.add_route("/socket.io/", route=sio_asgi_app) -fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) \ No newline at end of file +fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) + + +fastapi_app.add_event_handler("startup", create_tables) diff --git a/backend/v2/project_rewards/__init__.py b/backend/v2/project_rewards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/project_rewards/capped_quadriatic.py b/backend/v2/project_rewards/capped_quadriatic.py new file mode 100644 index 000000000..4e5d1db12 --- /dev/null +++ b/backend/v2/project_rewards/capped_quadriatic.py @@ -0,0 +1,138 @@ +from collections import defaultdict +from decimal import Decimal +from math import sqrt +from typing import Dict, NamedTuple + +from v2.allocations.models import AllocationWithUserUQScore + + +class CappedQuadriaticFunding(NamedTuple): + amounts_by_project: Dict[ + str, Decimal + ] # Sum of all allocation amounts for each project + matched_by_project: Dict[str, Decimal] # Sum of matched rewards for each project + amounts_total: Decimal # Sum of all allocation amounts for all projects + matched_total: Decimal # Sum of all matched rewards for all projects + + +MR_FUNDING_CAP_PERCENT = Decimal("0.2") + + +def capped_quadriatic_funding( + allocations: list[AllocationWithUserUQScore], + matched_rewards: int, + project_addresses: list[str], + MR_FUNDING_CAP_PERCENT: Decimal = MR_FUNDING_CAP_PERCENT, +) -> CappedQuadriaticFunding: + """ + Calculate capped quadratic funding based on a list of allocations. + + Args: + allocations (list[AllocationItem]): A list of allocation items, each containing a project address and an amount. + matched_rewards (int): The total amount of matched rewards available for distribution. + project_addresses (list[str] | None, optional): A list of project addresses to consider. If None, all projects in allocations are considered. Defaults to None. + 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. + """ + + # Group allocations by project + per_project_allocations: Dict[str, list[AllocationWithUserUQScore]] = defaultdict( + list + ) + for allocation in allocations: + per_project_allocations[allocation.project_address].append(allocation) + + # Variables necessary for calculation of quadratic funding + total_qf = Decimal(0) + qf_by_project: Dict[str, Decimal] = {} + + # Aggregate variables for amounts & matched rewards + amount_by_project: Dict[str, Decimal] = { + project_address: Decimal(0) for project_address in project_addresses + } + matched_by_project: Dict[str, Decimal] = { + project_address: Decimal(0) for project_address in project_addresses + } + matched_total = Decimal(0) + amounts_total = Decimal(0) + + # Calculate quadratic funding for each project + for project_address, allocations in per_project_allocations.items(): + qf = ( + sum( + ( + Decimal(sqrt(allocation.user_uq_score * allocation.amount)) + for allocation in allocations + ), + start=Decimal(0), + ) + ** 2 + ) + + total_qf += qf + qf_by_project[project_address] = qf + + # Aggregate amount by project + sum_amount = sum( + (Decimal(allocation.amount) for allocation in allocations), start=Decimal(0) + ) + amount_by_project[project_address] = sum_amount + amounts_total += sum_amount + + # Calculate funding cap + max_matched_reward = matched_rewards * MR_FUNDING_CAP_PERCENT + + # Calculate matched rewards for each project + for project_address, qf in qf_by_project.items(): + # Calculate matched rewards as proportion of quadratic funding + matched = qf / total_qf * matched_rewards if total_qf != 0 else Decimal(0) + + # Apply funding cap + matched_capped = min(matched, max_matched_reward) + + # Update matched rewards and total rewards + matched_by_project[project_address] = matched_capped + matched_total += matched_capped + + return CappedQuadriaticFunding( + amounts_by_project=amount_by_project, + matched_by_project=matched_by_project, + amounts_total=total_qf, + matched_total=matched_total, + ) + + +def cqf_calculate_total_leverage(matched_rewards: int, total_allocated: int) -> float: + if total_allocated == 0: + return 0.0 + + return matched_rewards / total_allocated + + +def cqf_calculate_individual_leverage( + new_allocations_amount: int, + project_addresses: list[str], + before_allocation_matched: Dict[str, Decimal], + after_allocation_matched: Dict[str, Decimal], +) -> float: + """Calculate the leverage of a user's new allocations in capped quadratic funding. + + This is a ratio of the sum of the absolute differences between the capped matched rewards before and after the user's allocation, to the total amount of the user's new allocations. + """ + + if new_allocations_amount == 0: + return 0.0 + + total_difference = Decimal(0) + for project_address in project_addresses: + before = before_allocation_matched.get(project_address, 0) + after = after_allocation_matched[project_address] + + difference = abs(before - after) + total_difference += difference + + leverage = total_difference / new_allocations_amount + + return float(leverage) diff --git a/backend/v2/projects/contracts.py b/backend/v2/projects/contracts.py index 09f28afe0..e2ae34df5 100644 --- a/backend/v2/projects/contracts.py +++ b/backend/v2/projects/contracts.py @@ -1,14 +1,14 @@ - import logging -from backend.v2.core.contracts import SmartContract + +from v2.core.contracts import SmartContract -class Projects(SmartContract): - async def get_project_addresses(self, epoch: int) -> list[str]: +class ProjectsContracts(SmartContract): + async def get_project_addresses(self, epoch_number: int) -> list[str]: logging.debug( - f"[Projects contract] Getting project addresses for epoch: {epoch}" + f"[Projects contract] Getting project addresses for epoch: {epoch_number}" ) - return await self.contract.functions.getProposalAddresses(epoch).call() + return await self.contract.functions.getProposalAddresses(epoch_number).call() async def get_project_cid(self): logging.debug("[Projects contract] Getting projects CID") diff --git a/backend/v2/projects/depdendencies.py b/backend/v2/projects/depdendencies.py index a59bf553c..49869386d 100644 --- a/backend/v2/projects/depdendencies.py +++ b/backend/v2/projects/depdendencies.py @@ -1,9 +1,22 @@ +from pydantic import Field +from pydantic_settings import BaseSettings +from v2.core.dependencies import w3_getter from web3 import AsyncWeb3 -from .contracts import Projects, PROJECTS_ABI + +from .contracts import PROJECTS_ABI, ProjectsContracts + + +class ProjectsSettings(BaseSettings): + projects_contract_address: str = Field( + validation_alias="proposals_contract_address" + ) # TODO: cache -def get_projects(w3: AsyncWeb3, projects_contract_address: str) -> Projects: +def get_projects(w3: AsyncWeb3, projects_contract_address: str) -> ProjectsContracts: + return ProjectsContracts(w3, PROJECTS_ABI, projects_contract_address) # type: ignore + - # projects.init_web3(w3, app.config["PROJECTS_CONTRACT_ADDRESS"]) - return Projects(w3, PROJECTS_ABI, projects_contract_address) +def projects_getter() -> ProjectsContracts: + settings = ProjectsSettings() # type: ignore + return get_projects(w3_getter(), settings.projects_contract_address) diff --git a/backend/v2/projects/services.py b/backend/v2/projects/services.py index ae9984aef..7db0286f9 100644 --- a/backend/v2/projects/services.py +++ b/backend/v2/projects/services.py @@ -1,34 +1,44 @@ - - +from decimal import Decimal from sqlalchemy.ext.asyncio import AsyncSession - -from backend.v2.allocations.repositories import sum_allocations_by_epoch -from backend.v2.epochs.repositories import get_pending_epoch_snapshot_by_epoch -from backend.v2.projects.contracts import Projects - +from v2.allocations.repositories import ( + get_allocations_with_user_uqs, + sum_allocations_by_epoch, +) +from v2.epoch_snapshots.repositories import get_pending_epoch_snapshot +from v2.epochs.subgraphs import EpochsSubgraph +from v2.project_rewards.capped_quadriatic import ( + CappedQuadriaticFunding, + capped_quadriatic_funding, +) +from v2.projects.contracts import ProjectsContracts +from v2.user_patron_mode.repositories import get_patrons_rewards async def get_projects_allocation_threshold( # Dependencies session: AsyncSession, - projects: Projects, + projects: ProjectsContracts, # Arguments epoch_number: int, project_count_multiplier: int = 1, ) -> int: - # PROJECTS_COUNT_MULTIPLIER = 1 # TODO: from settings? total_allocated = await sum_allocations_by_epoch(session, epoch_number) project_addresses = await projects.get_project_addresses(epoch_number) - return _calculate_threshold(total_allocated, len(project_addresses), project_count_multiplier) + print("total_allocated", total_allocated) + print("project_addresses", project_addresses) + + return _calculate_threshold( + total_allocated, len(project_addresses), project_count_multiplier + ) def _calculate_threshold( total_allocated: int, - projects_count: int, + projects_count: int, project_count_multiplier: int, ) -> int: return ( @@ -38,86 +48,85 @@ def _calculate_threshold( ) - -async def get_estimated_project_rewards( +async def get_estimated_project_rewards( # Dependencies session: AsyncSession, - projects: Projects, - + projects: ProjectsContracts, + epochs_subgraph: EpochsSubgraph, # Arguments epoch_number: int, -) -> int: - pass - +) -> 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") + async def get_estimated_project_matched_rewards_pending( # Dependencies session: AsyncSession, - projects: Projects, - + epochs_subgraph: EpochsSubgraph, + # projects: Projects, # Arguments epoch_number: int, ) -> int: - -# pending_snapshot = await get_pending_epoch_snapshot_by_epoch(session, epoch_number) - -# patrons_rewards = - -# def get_patrons_rewards(self, context: Context) -> int: -# epoch = context.epoch_details -# patrons = database.patrons.get_all_patrons_at_timestamp( -# epoch.finalized_timestamp.datetime() -# ) -# return database.budgets.get_sum_by_users_addresses_and_epoch( -# patrons, epoch.epoch_num -# ) - - -# def get_matched_rewards() -# patrons_mode: UserPatronMode - -# def get_matched_rewards(self, context: Context) -> int: -# pending_snapshot = database.pending_epoch_snapshot.get_by_epoch( -# context.epoch_details.epoch_num -# ) -# patrons_rewards = self.patrons_mode.get_patrons_rewards(context) -# matched_rewards_settings = context.epoch_settings.octant_rewards.matched_rewards - -# return matched_rewards_settings.calculate_matched_rewards( -# MatchedRewardsPayload( -# total_rewards=int(pending_snapshot.total_rewards), -# vanilla_individual_rewards=int( -# pending_snapshot.vanilla_individual_rewards -# ), -# patrons_rewards=patrons_rewards, -# staking_proceeds=int(pending_snapshot.eth_proceeds), -# locked_ratio=Decimal(pending_snapshot.locked_ratio), -# ire_percent=context.epoch_settings.octant_rewards.total_and_vanilla_individual_rewards.IRE_PERCENT, -# tr_percent=context.epoch_settings.octant_rewards.total_and_vanilla_individual_rewards.TR_PERCENT, -# ) -# ) - - -# project_rewards = get_estimated_project_rewards().rewards - -# def get_project_rewards(self, context: Context) -> ProjectRewardsResult: -# project_settings = context.epoch_settings.project # .rewards CappedQuadraticFundingProjectRewards -# # all_projects = context.projects_details.projects -# matched_rewards = self.octant_rewards.get_matched_rewards(context) -# allocations = database.allocations.get_all_with_uqs( -# context.epoch_details.epoch_num -# ) - -# allocations_payload = AllocationsPayload( -# before_allocations=allocations, user_new_allocations=[] -# ) -# projects_rewards = get_projects_rewards( -# project_settings, -# allocations_payload, -# all_projects, -# matched_rewards, -# ) - -# return projects_rewards + pending_snapshot = await get_pending_epoch_snapshot(session, epoch_number) + # if pending_snapshot is None: + # raise ValueError(f"No pending snapshot for epoch {epoch_number}") + + from app.infrastructure.database.models import PendingEpochSnapshot + + pending_snapshot = PendingEpochSnapshot( + eth_proceeds="412042049081445321216", + locked_ratio="0.094755727584613854218098688", + ) + + epoch_details = await epochs_subgraph.get_epoch_by_number(epoch_number) + patrons_rewards = await get_patrons_rewards( + 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, + ) + # fmt: on + + +def _calculate_percentage_matched_rewards( + locked_ratio: Decimal, + tr_percent: Decimal, + ire_percent: Decimal, + staking_proceeds: int, + patrons_rewards: int, + matched_rewards_percent: Decimal, # Config +) -> int: + if locked_ratio > tr_percent: + raise ValueError("Invalid Strategy - locked_ratio > tr_percent") + + if locked_ratio < ire_percent: + return int(matched_rewards_percent * staking_proceeds + patrons_rewards) + elif ire_percent <= locked_ratio < tr_percent: + return int((tr_percent - locked_ratio) * staking_proceeds + patrons_rewards) + return patrons_rewards diff --git a/backend/v2/uniqueness_quotients/__init__.py b/backend/v2/uniqueness_quotients/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/uniqueness_quotients/repositories.py b/backend/v2/uniqueness_quotients/repositories.py new file mode 100644 index 000000000..14e485fc2 --- /dev/null +++ b/backend/v2/uniqueness_quotients/repositories.py @@ -0,0 +1,45 @@ +from decimal import Decimal +from typing import Optional + +from app.infrastructure.database.models import UniquenessQuotient, User +from eth_utils import to_checksum_address +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from v2.users.repositories import get_user_by_address + + +async def get_uq_score_by_user_address( + session: AsyncSession, user_address: str, epoch_number: int +) -> Optional[Decimal]: + """Returns saved UQ score for a user in a given epoch. + None if the UQ score is not saved (allocation not made yet). + """ + + result = await session.execute( + select(UniquenessQuotient) + .join(User) + .filter(User.address == to_checksum_address(user_address)) + .filter(UniquenessQuotient.epoch == epoch_number) + ) + + uq = result.scalars().first() + return uq.validated_score if uq else None + + +async def save_uq_score_for_user_address( + session: AsyncSession, user_address: str, epoch_number: int, score: Decimal +): + """Saves UQ score for a user in a given epoch.""" + + user = await get_user_by_address(session, user_address) + + if not user: + return None + + uq_score = UniquenessQuotient( + epoch=epoch_number, + user_id=user.id, + score=str(score), + ) + + session.add(uq_score) diff --git a/backend/v2/uniqueness_quotients/services.py b/backend/v2/uniqueness_quotients/services.py new file mode 100644 index 000000000..aaafd0522 --- /dev/null +++ b/backend/v2/uniqueness_quotients/services.py @@ -0,0 +1,60 @@ +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 + +LOW_UQ_SCORE = Decimal("0.2") +MAX_UQ_SCORE = Decimal("1.0") + + +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, +) -> 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. + Otherwise, the UQ score is set to the low 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. + """ + + if gp_score >= uq_score_threshold: + return max_uq_score + + return low_uq_score + + +async def get_or_calculate_uq_score( + session: AsyncSession, + user_address: str, + epoch_number: int, + uq_score_threshold: float, + max_uq_score: Decimal = MAX_UQ_SCORE, + low_uq_score: Decimal = LOW_UQ_SCORE, +) -> 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. + Otherwise, it will be calculated based on the Gitcoin Passport score and saved for future reference. + """ + + # Check if the UQ score is already calculated and saved + uq_score = await get_uq_score_by_user_address(session, user_address, epoch_number) + if uq_score: + return uq_score + + # Otherwise, calculate the UQ score based on the gitcoin passport score + gp_score = await get_gitcoin_passport_score(session, user_address) + uq_score = calculate_uq_score( + gp_score, uq_score_threshold, max_uq_score, low_uq_score + ) + + # and save the UQ score for future reference + await save_uq_score_for_user_address(session, user_address, epoch_number, uq_score) + + return uq_score diff --git a/backend/v2/user_patron_mode/__init__.py b/backend/v2/user_patron_mode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/user_patron_mode/repositories.py b/backend/v2/user_patron_mode/repositories.py new file mode 100644 index 000000000..38c16c174 --- /dev/null +++ b/backend/v2/user_patron_mode/repositories.py @@ -0,0 +1,116 @@ +from datetime import datetime +from typing import List + +from app.infrastructure.database.models import Budget, PatronModeEvent, User +from sqlalchemy import Integer, cast, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import aliased +from v2.users.repositories import get_user_by_address + + +async def get_all_patrons_at_timestamp( + session: AsyncSession, dt: datetime +) -> List[str]: + """ + From PatronModeEvent table, get all the user addresses that have patron_mode_enabled=True at a given timestamp. + """ + + subquery = ( + select( + PatronModeEvent.user_address, + PatronModeEvent.patron_mode_enabled, + PatronModeEvent.created_at, + ) + .filter(PatronModeEvent.created_at <= dt) + .order_by(PatronModeEvent.user_address, PatronModeEvent.created_at.desc()) + .subquery() + ) + + alias = aliased(PatronModeEvent, subquery) + + result = await session.execute( + select(alias.user_address) + .filter(alias.patron_mode_enabled == True) + .group_by(alias.user_address) + ) + + patrons = [row[0] for row in result.fetchall()] + return patrons + + +async def get_budget_sum_by_users_addresses_and_epoch( + session: AsyncSession, users_addresses: List[str], epoch_number: int +) -> int: + """ + Sum the budgets of given users for a given epoch. + """ + result = await session.execute( + select(func.sum(cast(Budget.budget, Integer))) + .join(User) + .filter(User.address.in_(users_addresses), Budget.epoch == epoch_number) + ) + total_budget = result.scalar() + + if total_budget is None: + return 0 + + return total_budget + + +async def get_patrons_rewards( + session: AsyncSession, finalized_timestamp: datetime, epoch_number: int +) -> int: + """ + Patron rewards are the sum of budgets of all patrons for a given epoch. + """ + + patrons = await get_all_patrons_at_timestamp(session, finalized_timestamp) + return await get_budget_sum_by_users_addresses_and_epoch( + session, patrons, epoch_number + ) + + +async def get_budget_by_user_address_and_epoch( + session: AsyncSession, user_address: str, epoch: int +) -> int | None: + """ + Get the budget of a user for a given epoch. + """ + + user = await get_user_by_address(session, user_address) + if user is None: + return None + + result = await session.execute( + select(Budget.budget) + .filter(Budget.user_id == user.id) + .filter(Budget.epoch == epoch) + ) + + budget = result.scalar() + + if budget is None: + return None + + return int(budget) + + +async def user_is_patron_with_budget( + session: AsyncSession, + user_address: str, + epoch_number: int, + finalized_timestamp: datetime, +) -> bool: + """ + Check if a user is a patron with a budget for a given epoch. + """ + + patrons = await get_all_patrons_at_timestamp(session, finalized_timestamp) + if user_address not in patrons: + return False + + budget = await get_budget_by_user_address_and_epoch( + session, user_address, epoch_number + ) + return budget is not None diff --git a/backend/v2/users/__init__.py b/backend/v2/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/v2/users/repositories.py b/backend/v2/users/repositories.py new file mode 100644 index 000000000..b28dc4777 --- /dev/null +++ b/backend/v2/users/repositories.py @@ -0,0 +1,11 @@ +from app.infrastructure.database.models import User +from eth_utils import to_checksum_address +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + + +async def get_user_by_address(session: AsyncSession, user_address: str) -> User | None: + user_address = to_checksum_address(user_address) + + result = await session.execute(select(User).filter(User.address == user_address)) + return result.scalar_one_or_none() From 56a5f6ed33952440b31887e6dfa7317873fe781c Mon Sep 17 00:00:00 2001 From: adam-gf Date: Tue, 27 Aug 2024 21:08:26 +0200 Subject: [PATCH 4/6] Small fixes after local testing --- backend/v2/allocations/repositories.py | 95 ------------------ backend/v2/allocations/services.py | 10 +- backend/v2/allocations/socket.py | 127 ++----------------------- backend/v2/epochs/contracts.py | 1 - backend/v2/main.py | 6 +- backend/v2/projects/services.py | 15 ++- 6 files changed, 22 insertions(+), 232 deletions(-) diff --git a/backend/v2/allocations/repositories.py b/backend/v2/allocations/repositories.py index 48d2c6200..3dafd1311 100644 --- a/backend/v2/allocations/repositories.py +++ b/backend/v2/allocations/repositories.py @@ -35,31 +35,6 @@ async def get_allocations_with_user_uqs( ) -> list[AllocationWithUserUQScore]: """Get all allocations for a given epoch, including the uniqueness quotients of the users.""" - # result = await session.execute( - # select(Allocation) - # .filter(Allocation.epoch == epoch) - # .filter(Allocation.deleted_at.is_(None)) - # .options(joinedload(Allocation.user).joinedload(User.uniqueness_quotients)) - # ) - # allocations = result.scalars().all() - - # return [ - # AllocationWithUserUQScore( - # project_address=a.project_address, - # amount=int(a.amount), - # user_address=a.user.address, - # user_uq_score=next( - # ( - # uq.validated_score - # for uq in a.user.uniqueness_quotients - # if uq.epoch == epoch - # ), - # None, - # ), - # ) - # for a in allocations - # ] - result = await session.execute( select( Allocation.project_address, @@ -74,22 +49,6 @@ async def get_allocations_with_user_uqs( .filter(UniquenessQuotient.epoch == epoch_number) ) - # result = await session.execute( - # select( - # Allocation.id.label('allocation_id'), - # Allocation.amount.label('allocation_amount'), - # User.id.label('user_id'), - # User.name.label('user_name'), - # UniquenessQuotient.id.label('uq_id'), - # UniquenessQuotient.score.label('uq_score') - # ) - # .join(User, Allocation.user_id == User.id) - # .join(UniquenessQuotient, UniquenessQuotient.user_id == User.id) - # .filter(Allocation.epoch == epoch_number) - # .filter(Allocation.deleted_at.is_(None)) - # .filter(UniquenessQuotient.epoch == epoch_number) - # ) - rows = result.all() return [ @@ -103,36 +62,6 @@ async def get_allocations_with_user_uqs( ] -# allocations = database.allocations.get_all_with_uqs( -# context.epoch_details.epoch_num -# ) - -# def get_all_allocations_with_uqs(epoch: int) -> List[AllocationDTO]: -# allocations = ( -# Allocation.query.filter_by(epoch=epoch) -# .filter(Allocation.deleted_at.is_(None)) -# .options(joinedload(Allocation.user).joinedload(User.uniqueness_quotients)) -# .all() -# ) - -# return [ -# AllocationDTO( -# amount=int(a.amount), -# project_address=a.project_address, -# user_address=a.user.address, -# uq_score=next( -# ( -# uq.validated_score -# for uq in a.user.uniqueness_quotients -# if uq.epoch == epoch -# ), -# None, -# ), -# ) -# for a in allocations -# ] - - async def soft_delete_user_allocations_by_epoch( session: AsyncSession, user_address: str, @@ -239,27 +168,3 @@ async def get_donations_by_project( ) for a in allocations ] - - # query: Query = Allocation.query.filter_by( - # project_address=to_checksum_address(project_address), epoch=epoch - # ).options(joinedload(Allocation.user)) - - # if not with_deleted: - # query = query.filter(Allocation.deleted_at.is_(None)) - - # return query.all() - - # def get_allocations_by_project( - # self, context: Context, project_address: str - # ) -> List[ProjectDonationDTO]: - # allocations = database.allocations.get_all_by_project_addr_and_epoch( - # project_address, context.epoch_details.epoch_num - # ) - - # return [ - # ProjectDonationDTO( - # donor=a.user.address, amount=int(a.amount), project=project_address - # ) - # for a in allocations - # if int(a.amount) != 0 - # ] diff --git a/backend/v2/allocations/services.py b/backend/v2/allocations/services.py index 5fdea2ea4..02cd2b1d1 100644 --- a/backend/v2/allocations/services.py +++ b/backend/v2/allocations/services.py @@ -225,12 +225,12 @@ async def verify_logic( session, payload.user_address, epoch_number ) - # if user_budget is None: - # raise exceptions.BudgetNotFound(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() + # 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( diff --git a/backend/v2/allocations/socket.py b/backend/v2/allocations/socket.py index 508d1a086..7ea9718ee 100644 --- a/backend/v2/allocations/socket.py +++ b/backend/v2/allocations/socket.py @@ -1,19 +1,14 @@ import logging -from typing import List import socketio -from app.engine.projects.rewards import ProjectRewardDTO -from app.exceptions import OctantException # from app.extensions import socketio, epochs -from app.infrastructure.exception_handler import UNEXPECTED_EXCEPTION, ExceptionHandler -from app.modules.dto import ProjectDonationDTO 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, get_w3, w3_getter -from v2.epochs.dependencies import epochs_getter, epochs_subgraph_getter, get_epochs -from v2.projects.depdendencies import get_projects, projects_getter +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.projects.services import ( get_estimated_project_rewards, get_projects_allocation_threshold, @@ -37,28 +32,12 @@ async def on_connect(self, sid: str, environ: dict): Handle client connection """ - print("Type of sid", type(sid)) - print("Type of environ", type(environ)) - logging.debug("Client connected") - print("Epochs are here") - - await self.emit("epoch", {"epoch": "fuckup"}) - # We send the data only in PENDING state pending_epoch_number = await self.epochs_contracts.get_pending_epoch() - - epoch_end = await self.epochs_contracts.get_current_epoch_end() - - print("epocg_end", epoch_end) - print("Pending epoch =", pending_epoch_number) - - # We do not handle requests outside of pending epoch state - # if pending_epoch_number is None: - # return - - pending_epoch_number = 124 + if pending_epoch_number is None: + return async with self.db_session() as session: threshold = await get_projects_allocation_threshold( @@ -85,7 +64,6 @@ async def on_connect(self, sid: str, environ: dict): for project_address in project_rewards.amounts_by_project.keys() ] - # project_rewards = get_estimated_project_rewards().rewards await self.emit("project_rewards", rewards) async def on_disconnect(self, sid): @@ -96,15 +74,12 @@ async def on_allocate(self, sid: str, 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() - # if pending_epoch_number is None: - # return - - print("message", data, type(data)) + # We do not handle requests outside of pending epoch state (Allocation Window) + pending_epoch_number = await self.epochs_contracts.get_pending_epoch() + if pending_epoch_number is None: + return request = from_dict(data) - pending_epoch_number = 124 async with self.db_session() as session: await allocate( @@ -154,41 +129,6 @@ async def on_allocate(self, sid: str, data: dict): {"project": project_address, "donors": donations}, ) - # msg = json.loads(msg) - - # print("MEssage", msg) - - # is_manually_edited = data.get("isManuallyEdited", None) - # user_address = data["userAddress"] - # # is_manually_edited = ( - # # msg["isManuallyEdited"] if "isManuallyEdited" in msg else None - # # ) - # logging.info(f"User allocation payload: {msg}") - - # controller.allocate( - # user_address, - # msg, - # is_manually_edited=is_manually_edited, - # ) - # socketio.logger.info(f"User: {user_address} allocated successfully") - - # threshold = get_projects_allocation_threshold() - # await self.emit("threshold", {"threshold": str(threshold)}, broadcast=True) - - # project_rewards = get_estimated_project_rewards().rewards - # await self.emit( - # "project_rewards", - # _serialize_project_rewards(project_rewards), - # broadcast=True, - # ) - # for project in project_rewards: - # donors = controller.get_all_donations_by_project(project.address) - # await self.emit( - # "project_donors", - # {"project": project.address, "donors": _serialize_donors(donors)}, - # broadcast=True, - # ) - def from_dict(data: dict) -> UserAllocationRequest: """ @@ -234,52 +174,3 @@ def from_dict(data: dict) -> UserAllocationRequest: is_manually_edited = is_manually_edited, ) # fmt: on - - -# def state_context(epoch_state: EpochState) -> Context: -# epoch_num = get_epoch_number(epoch_state) -# return build_context(epoch_num, epoch_state, with_block_range) - - -# @socketio.on("project_donors") -# def handle_project_donors(project_address: str): -# print("Project donors") -# emit( -# "project_donors", -# {"project": project_address, "donors": []}, -# ) -# donors = controller.get_all_donations_by_project(project_address) -# emit( -# "project_donors", -# {"project": project_address, "donors": _serialize_donors(donors)}, -# ) - - -# @socketio. -def default_error_handler(e): - ExceptionHandler.print_stacktrace(e) - if isinstance(e, OctantException): - emit("exception", {"message": str(e.message)}) - else: - emit("exception", {"message": UNEXPECTED_EXCEPTION}) - - -def _serialize_project_rewards(project_rewards: List[ProjectRewardDTO]) -> List[dict]: - return [ - { - "address": project_reward.address, - "allocated": str(project_reward.allocated), - "matched": str(project_reward.matched), - } - for project_reward in project_rewards - ] - - -def _serialize_donors(donors: List[ProjectDonationDTO]) -> List[dict]: - return [ - { - "address": donor.donor, - "amount": str(donor.amount), - } - for donor in donors - ] diff --git a/backend/v2/epochs/contracts.py b/backend/v2/epochs/contracts.py index 565d353c2..58d919369 100644 --- a/backend/v2/epochs/contracts.py +++ b/backend/v2/epochs/contracts.py @@ -26,7 +26,6 @@ async def get_current_epoch(self) -> int: async def get_pending_epoch(self) -> Optional[int]: try: logging.debug("[Epochs contract] Getting pending epoch") - # return 5 return await self.contract.functions.getPendingEpoch().call() except exceptions.ContractLogicError: logging.warning("[Epochs contract] No pending epoch") diff --git a/backend/v2/main.py b/backend/v2/main.py index 4fb429fed..162aeb833 100644 --- a/backend/v2/main.py +++ b/backend/v2/main.py @@ -2,7 +2,6 @@ import socketio from fastapi import FastAPI from v2.allocations.socket import AllocateNamespace -from v2.core.dependencies import create_tables fastapi_app = FastAPI() @@ -16,10 +15,9 @@ async def fastapi_endpoint(): sio.register_namespace(AllocateNamespace("/")) sio_asgi_app = socketio.ASGIApp(socketio_server=sio, other_asgi_app=fastapi_app) -# app.mount("/static", StaticFiles(directory="static"), name="static") -# fastapi_app.mount("/", sio_asgi_app) fastapi_app.add_route("/socket.io/", route=sio_asgi_app) fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) -fastapi_app.add_event_handler("startup", create_tables) +# from v2.core.dependencies import create_tables +# fastapi_app.add_event_handler("startup", create_tables) diff --git a/backend/v2/projects/services.py b/backend/v2/projects/services.py index 7db0286f9..c9f2d0463 100644 --- a/backend/v2/projects/services.py +++ b/backend/v2/projects/services.py @@ -86,16 +86,13 @@ async def get_estimated_project_matched_rewards_pending( # Arguments epoch_number: int, ) -> int: - pending_snapshot = await get_pending_epoch_snapshot(session, epoch_number) - # if pending_snapshot is None: - # raise ValueError(f"No pending snapshot for epoch {epoch_number}") - - from app.infrastructure.database.models import PendingEpochSnapshot + """ + Get the estimated matched rewards for the pending epoch. + """ - pending_snapshot = PendingEpochSnapshot( - eth_proceeds="412042049081445321216", - locked_ratio="0.094755727584613854218098688", - ) + pending_snapshot = await get_pending_epoch_snapshot(session, epoch_number) + if pending_snapshot is None: + raise ValueError(f"No pending snapshot for epoch {epoch_number}") epoch_details = await epochs_subgraph.get_epoch_by_number(epoch_number) patrons_rewards = await get_patrons_rewards( From 501c1eb328615e0c4b251119e4b94509619c1496 Mon Sep 17 00:00:00 2001 From: adam-gf Date: Tue, 27 Aug 2024 21:13:28 +0200 Subject: [PATCH 5/6] Additional fixes to alighn with what was before --- backend/app/extensions.py | 6 ----- .../app/infrastructure/contracts/epochs.py | 1 - backend/startup.py | 26 ++++--------------- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 78e0dc2f6..8a749b7c8 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -23,12 +23,6 @@ description="Octant REST API documentation", catch_all_404s=True, ) -# from flask import current_app as app - -# socketio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*", logger=True) - -# async_mode="asgi", cors_allowed_origins="*", client_manager=mgr -# ) socketio = SocketIO(cors_allowed_origins="*") db = SQLAlchemy() migrate = Migrate() diff --git a/backend/app/infrastructure/contracts/epochs.py b/backend/app/infrastructure/contracts/epochs.py index bb47df0f3..b2c95ada8 100644 --- a/backend/app/infrastructure/contracts/epochs.py +++ b/backend/app/infrastructure/contracts/epochs.py @@ -27,7 +27,6 @@ def get_current_epoch(self) -> int: def get_pending_epoch(self) -> Optional[int]: try: app.logger.debug("[Epochs contract] Getting pending epoch") - return 5 return self.contract.functions.getPendingEpoch().call() except exceptions.ContractLogicError: app.logger.warning("[Epochs contract] No pending epoch") diff --git a/backend/startup.py b/backend/startup.py index 622eee6b9..c4ccecc60 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -1,11 +1,7 @@ -import asyncio -from concurrent.futures import ThreadPoolExecutor -import io import os -from fastapi import FastAPI, Request +from fastapi import Request from fastapi.middleware.wsgi import WSGIMiddleware -from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse -from starlette.responses import Response + from starlette.middleware.base import BaseHTTPMiddleware @@ -58,17 +54,9 @@ def teardown_session(*args, **kwargs): from v2.main import fastapi_app -# Create FastAPI app -# fastapi_app = FastAPI() - -# @fastapi_app.get("/fastapi-endpoint") -# async def fastapi_endpoint(): -# return {"message": "This is a FastAPI endpoint."} - # 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): @@ -89,11 +77,7 @@ async def dispatch(self, request: Request, call_next): fastapi_app.add_middleware(PathCheckMiddleware) -# from app.extensions import socketio as our_socketio -# import socketio - -# sio_asgi_app = socketio.ASGIApp(socketio_server=our_socketio, other_asgi_app=fastapi_app) +if __name__ == "__main__": + import uvicorn -# # app.mount("/static", StaticFiles(directory="static"), name="static") -# fastapi_app.add_route("/socket.io/", route=sio_asgi_app) -# fastapi_app.add_websocket_route("/socket.io/", sio_asgi_app) + uvicorn.run(fastapi_app, host="0.0.0.0", port=5000) \ No newline at end of file From 3cbf5158276a2f592e12589ab7416e193ab72aba Mon Sep 17 00:00:00 2001 From: adam-gf Date: Tue, 3 Sep 2024 15:02:22 +0200 Subject: [PATCH 6/6] Updates based on pr comments and extracting dependencies --- backend/app/__init__.py | 1 - backend/startup.py | 5 +- backend/v2/allocations/dependencies.py | 55 ++++ backend/v2/allocations/repositories.py | 8 +- .../v2/allocations/{models.py => schemas.py} | 0 backend/v2/allocations/services.py | 276 ++++------------- backend/v2/allocations/socket.py | 277 +++++++++++++----- backend/v2/allocations/validators.py | 185 ++++++++++++ backend/v2/core/dependencies.py | 49 +++- backend/v2/epochs/dependencies.py | 28 +- backend/v2/gitcoin_passport/__init__.py | 0 backend/v2/gitcoin_passport/repositories.py | 19 -- backend/v2/gitcoin_passport/services.py | 25 -- .../v2/project_rewards/capped_quadriatic.py | 47 ++- backend/v2/projects/depdendencies.py | 85 +++++- backend/v2/projects/services.py | 123 +++++--- .../v2/uniqueness_quotients/dependencies.py | 36 +++ .../v2/uniqueness_quotients/repositories.py | 17 +- backend/v2/uniqueness_quotients/services.py | 60 +++- backend/v2/user_patron_mode/repositories.py | 2 +- 20 files changed, 857 insertions(+), 441 deletions(-) create mode 100644 backend/v2/allocations/dependencies.py rename backend/v2/allocations/{models.py => schemas.py} (100%) create mode 100644 backend/v2/allocations/validators.py delete mode 100644 backend/v2/gitcoin_passport/__init__.py delete mode 100644 backend/v2/gitcoin_passport/repositories.py delete mode 100644 backend/v2/gitcoin_passport/services.py create mode 100644 backend/v2/uniqueness_quotients/dependencies.py 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) )