diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 1608020af3..c5000845ff 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -7,7 +7,6 @@ db, migrate, cors, - socketio, cache, init_web3, api, @@ -47,7 +46,8 @@ def register_extensions(app): cors.init_app(app) db.init_app(app) migrate.init_app(app, db) - socketio.init_app(app) + # This is meant to be disabled because we migrate to FastAPI + # socketio.init_app(app) cache.init_app(app) init_scheduler(app) init_logger(app) diff --git a/backend/app/constants.py b/backend/app/constants.py index aca64a667f..d62dcecfac 100644 --- a/backend/app/constants.py +++ b/backend/app/constants.py @@ -981,6 +981,7 @@ TIMEOUT_LIST_NOT_MAINNET = { "0xdf486eec7b89c390569194834a2f7a71da05ee13", "0x689f1a51c177cce66e3afdca4b1ded7721f531f9", + "0x018d43ac91432d00c4ad1531c98b6ccd2b352538", } GUEST_LIST_STAMP_PROVIDERS = [ diff --git a/backend/app/infrastructure/database/allocations.py b/backend/app/infrastructure/database/allocations.py index 9dec498d88..6307726c55 100644 --- a/backend/app/infrastructure/database/allocations.py +++ b/backend/app/infrastructure/database/allocations.py @@ -259,12 +259,13 @@ def get_allocation_request_by_user_and_epoch( def get_user_last_allocation_request(user_address: str) -> AllocationRequest | None: - return ( + result = ( AllocationRequest.query.join(User, User.id == AllocationRequest.user_id) .filter(User.address == user_address) .order_by(AllocationRequest.nonce.desc()) .first() ) + return result def get_user_allocation_epoch_count(user_address: str) -> int: diff --git a/backend/app/infrastructure/events.py b/backend/app/infrastructure/events.py index 78cc07ded7..fcff9bee44 100644 --- a/backend/app/infrastructure/events.py +++ b/backend/app/infrastructure/events.py @@ -34,6 +34,13 @@ def handle_connect(): {"project": project.address, "donors": _serialize_donors(donors)}, ) + 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") def handle_disconnect(): diff --git a/backend/app/logging.py b/backend/app/logging.py index 958eb9c0eb..239b8ec059 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/poetry.lock b/backend/poetry.lock index 209ea55a9f..64a06f342d 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" @@ -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" @@ -212,6 +230,63 @@ files = [ {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] +[[package]] +name = "asyncpg" +version = "0.29.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169"}, + {file = "asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb"}, + {file = "asyncpg-0.29.0-cp310-cp310-win32.whl", hash = "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449"}, + {file = "asyncpg-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b"}, + {file = "asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675"}, + {file = "asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175"}, + {file = "asyncpg-0.29.0-cp312-cp312-win32.whl", hash = "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02"}, + {file = "asyncpg-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9"}, + {file = "asyncpg-0.29.0-cp38-cp38-win32.whl", hash = "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408"}, + {file = "asyncpg-0.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c"}, + {file = "asyncpg-0.29.0-cp39-cp39-win32.whl", hash = "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2"}, + {file = "asyncpg-0.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8"}, + {file = "asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""} + +[package.extras] +docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] + [[package]] name = "attrs" version = "23.2.0" @@ -1103,6 +1178,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" @@ -1594,6 +1689,54 @@ doc = ["sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] lint = ["black (>=22)", "flake8 (==6.0.0)", "flake8-bugbear (==23.3.23)", "isort (>=5.10.1)", "mypy (==0.971)", "pydocstyle (>=5.0.0)"] test = ["eth-utils (>=1.0.1,<3)", "hypothesis (>=3.44.24,<=6.31.6)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] +[[package]] +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + [[package]] name = "idna" version = "3.7" @@ -1644,6 +1787,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" @@ -2002,6 +2159,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" @@ -2479,6 +2682,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" @@ -2669,13 +2892,13 @@ docs = ["sphinx"] [[package]] name = "python-socketio" -version = "5.11.3" +version = "5.11.4" description = "Socket.IO server and client for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_socketio-5.11.3-py3-none-any.whl", hash = "sha256:2a923a831ff70664b7c502df093c423eb6aa93c1ce68b8319e840227a26d8b69"}, - {file = "python_socketio-5.11.3.tar.gz", hash = "sha256:194af8cdbb7b0768c2e807ba76c7abc288eb5bb85559b7cddee51a6bc7a65737"}, + {file = "python_socketio-5.11.4-py3-none-any.whl", hash = "sha256:42efaa3e3e0b166fc72a527488a13caaac2cefc76174252486503bd496284945"}, + {file = "python_socketio-5.11.4.tar.gz", hash = "sha256:8b0b8ff2964b2957c865835e936310190639c00310a47d77321a594d1665355e"}, ] [package.dependencies] @@ -2731,6 +2954,68 @@ files = [ {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + [[package]] name = "rapidfuzz" version = "3.9.3" @@ -2997,6 +3282,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" @@ -3202,6 +3514,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" @@ -3269,24 +3598,189 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "uvicorn" +version = "0.31.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.31.0-py3-none-any.whl", hash = "sha256:cac7be4dd4d891c363cd942160a7b02e69150dcbc7a36be04d5f4af4b17c8ced"}, + {file = "uvicorn-0.31.0.tar.gz", hash = "sha256:13bc21373d103859f68fe739608e2eb054a816dea79189bc3ca08ea89a275906"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[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 = "uvloop" +version = "0.20.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9ebafa0b96c62881d5cafa02d9da2e44c23f9f0cd829f3a32a6aff771449c996"}, + {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:35968fc697b0527a06e134999eef859b4034b37aebca537daeb598b9d45a137b"}, + {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b16696f10e59d7580979b420eedf6650010a4a9c3bd8113f24a103dfdb770b10"}, + {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b04d96188d365151d1af41fa2d23257b674e7ead68cfd61c725a422764062ae"}, + {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:94707205efbe809dfa3a0d09c08bef1352f5d3d6612a506f10a319933757c006"}, + {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89e8d33bb88d7263f74dc57d69f0063e06b5a5ce50bb9a6b32f5fcbe655f9e73"}, + {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037"}, + {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9"}, + {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e"}, + {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756"}, + {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0"}, + {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf"}, + {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d"}, + {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e"}, + {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9"}, + {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab"}, + {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5"}, + {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00"}, + {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0e94b221295b5e69de57a1bd4aeb0b3a29f61be6e1b478bb8a69a73377db7ba"}, + {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fee6044b64c965c425b65a4e17719953b96e065c5b7e09b599ff332bb2744bdf"}, + {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:265a99a2ff41a0fd56c19c3838b29bf54d1d177964c300dad388b27e84fd7847"}, + {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10c2956efcecb981bf9cfb8184d27d5d64b9033f917115a960b83f11bfa0d6b"}, + {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e7d61fe8e8d9335fac1bf8d5d82820b4808dd7a43020c149b63a1ada953d48a6"}, + {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2beee18efd33fa6fdb0976e18475a4042cd31c7433c866e8a09ab604c7c22ff2"}, + {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8c36fdf3e02cec92aed2d44f63565ad1522a499c654f07935c8f9d04db69e95"}, + {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0fac7be202596c7126146660725157d4813aa29a4cc990fe51346f75ff8fde7"}, + {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0fba61846f294bce41eb44d60d58136090ea2b5b99efd21cbdf4e21927c56a"}, + {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95720bae002ac357202e0d866128eb1ac82545bcf0b549b9abe91b5178d9b541"}, + {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:36c530d8fa03bfa7085af54a48f2ca16ab74df3ec7108a46ba82fd8b411a2315"}, + {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e97152983442b499d7a71e44f29baa75b3b02e65d9c44ba53b10338e98dedb66"}, + {file = "uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "watchfiles" +version = "0.24.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchfiles-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:083dc77dbdeef09fa44bb0f4d1df571d2e12d8a8f985dccde71ac3ac9ac067a0"}, + {file = "watchfiles-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e94e98c7cb94cfa6e071d401ea3342767f28eb5a06a58fafdc0d2a4974f4f35c"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82ae557a8c037c42a6ef26c494d0631cacca040934b101d001100ed93d43f361"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acbfa31e315a8f14fe33e3542cbcafc55703b8f5dcbb7c1eecd30f141df50db3"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b74fdffce9dfcf2dc296dec8743e5b0332d15df19ae464f0e249aa871fc1c571"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:449f43f49c8ddca87c6b3980c9284cab6bd1f5c9d9a2b00012adaaccd5e7decd"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4abf4ad269856618f82dee296ac66b0cd1d71450fc3c98532d93798e73399b7a"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f895d785eb6164678ff4bb5cc60c5996b3ee6df3edb28dcdeba86a13ea0465e"}, + {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ae3e208b31be8ce7f4c2c0034f33406dd24fbce3467f77223d10cd86778471c"}, + {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2efec17819b0046dde35d13fb8ac7a3ad877af41ae4640f4109d9154ed30a188"}, + {file = "watchfiles-0.24.0-cp310-none-win32.whl", hash = "sha256:6bdcfa3cd6fdbdd1a068a52820f46a815401cbc2cb187dd006cb076675e7b735"}, + {file = "watchfiles-0.24.0-cp310-none-win_amd64.whl", hash = "sha256:54ca90a9ae6597ae6dc00e7ed0a040ef723f84ec517d3e7ce13e63e4bc82fa04"}, + {file = "watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428"}, + {file = "watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823"}, + {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab"}, + {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec"}, + {file = "watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d"}, + {file = "watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c"}, + {file = "watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633"}, + {file = "watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a"}, + {file = "watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234"}, + {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef"}, + {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968"}, + {file = "watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444"}, + {file = "watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896"}, + {file = "watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418"}, + {file = "watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48"}, + {file = "watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f"}, + {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b"}, + {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18"}, + {file = "watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07"}, + {file = "watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366"}, + {file = "watchfiles-0.24.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ee82c98bed9d97cd2f53bdb035e619309a098ea53ce525833e26b93f673bc318"}, + {file = "watchfiles-0.24.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fd92bbaa2ecdb7864b7600dcdb6f2f1db6e0346ed425fbd01085be04c63f0b05"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f83df90191d67af5a831da3a33dd7628b02a95450e168785586ed51e6d28943c"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fca9433a45f18b7c779d2bae7beeec4f740d28b788b117a48368d95a3233ed83"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b995bfa6bf01a9e09b884077a6d37070464b529d8682d7691c2d3b540d357a0c"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed9aba6e01ff6f2e8285e5aa4154e2970068fe0fc0998c4380d0e6278222269b"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5171ef898299c657685306d8e1478a45e9303ddcd8ac5fed5bd52ad4ae0b69b"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4933a508d2f78099162da473841c652ad0de892719043d3f07cc83b33dfd9d91"}, + {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95cf3b95ea665ab03f5a54765fa41abf0529dbaf372c3b83d91ad2cfa695779b"}, + {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:01def80eb62bd5db99a798d5e1f5f940ca0a05986dcfae21d833af7a46f7ee22"}, + {file = "watchfiles-0.24.0-cp38-none-win32.whl", hash = "sha256:4d28cea3c976499475f5b7a2fec6b3a36208656963c1a856d328aeae056fc5c1"}, + {file = "watchfiles-0.24.0-cp38-none-win_amd64.whl", hash = "sha256:21ab23fdc1208086d99ad3f69c231ba265628014d4aed31d4e8746bd59e88cd1"}, + {file = "watchfiles-0.24.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b665caeeda58625c3946ad7308fbd88a086ee51ccb706307e5b1fa91556ac886"}, + {file = "watchfiles-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5c51749f3e4e269231510da426ce4a44beb98db2dce9097225c338f815b05d4f"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b2509f08761f29a0fdad35f7e1638b8ab1adfa2666d41b794090361fb8b855"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a60e2bf9dc6afe7f743e7c9b149d1fdd6dbf35153c78fe3a14ae1a9aee3d98b"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7d9b87c4c55e3ea8881dfcbf6d61ea6775fffed1fedffaa60bd047d3c08c430"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78470906a6be5199524641f538bd2c56bb809cd4bf29a566a75051610bc982c3"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07cdef0c84c03375f4e24642ef8d8178e533596b229d32d2bbd69e5128ede02a"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d337193bbf3e45171c8025e291530fb7548a93c45253897cd764a6a71c937ed9"}, + {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ec39698c45b11d9694a1b635a70946a5bad066b593af863460a8e600f0dff1ca"}, + {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e28d91ef48eab0afb939fa446d8ebe77e2f7593f5f463fd2bb2b14132f95b6e"}, + {file = "watchfiles-0.24.0-cp39-none-win32.whl", hash = "sha256:7138eff8baa883aeaa074359daabb8b6c1e73ffe69d5accdc907d62e50b1c0da"}, + {file = "watchfiles-0.24.0-cp39-none-win_amd64.whl", hash = "sha256:b3ef2c69c655db63deb96b3c3e587084612f9b1fa983df5e0c3379d41307467f"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:632676574429bee8c26be8af52af20e0c718cc7f5f67f3fb658c71928ccd4f7f"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a2a9891723a735d3e2540651184be6fd5b96880c08ffe1a98bae5017e65b544b"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7fa2bc0efef3e209a8199fd111b8969fe9db9c711acc46636686331eda7dd4"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:96619302d4374de5e2345b2b622dc481257a99431277662c30f606f3e22f42be"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:85d5f0c7771dcc7a26c7a27145059b6bb0ce06e4e751ed76cdf123d7039b60b5"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951088d12d339690a92cef2ec5d3cfd957692834c72ffd570ea76a6790222777"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fb58bcaa343fedc6a9e91f90195b20ccb3135447dc9e4e2570c3a39565853e"}, + {file = "watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + [[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" @@ -3298,10 +3792,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" @@ -3521,4 +4015,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8beb2e0b06481e87b431a937a21c11d21f06810f5b7497781c4be087d48e4b44" +content-hash = "bc7f7d04b03d2aeaafe48b29faba1ac5cce81d9d6ab1869452c170efffd91b47" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 683adf29af..a7d143fed7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,14 @@ pandas = "^2.2.0" 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 = {extras = ["standard"], version = "^0.31.0"} +asyncpg = "^0.29.0" +uvloop = "^0.20.0" +python-socketio = "^5.11.4" [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" @@ -43,6 +51,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/startup.py b/backend/startup.py index 861c38017a..9947a2aa48 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -1,21 +1,12 @@ -# !!! 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 os +from fastapi import Request +from fastapi.middleware.wsgi import WSGIMiddleware + -os.environ["EVENTLET_NO_GREENDNS"] = "yes" -import eventlet # noqa +from starlette.middleware.base import BaseHTTPMiddleware -eventlet.monkey_patch() +from app import create_app as create_flask_app +from app.extensions import db as flask_db if os.getenv("SENTRY_DSN"): import sentry_sdk @@ -51,16 +42,45 @@ def sentry_before_send(event, hint): before_send=sentry_before_send, ) -from app import create_app # noqa -from app.extensions import db # noqa -app = create_app() +flask_app = create_flask_app() -@app.teardown_request +@flask_app.teardown_request def teardown_session(*args, **kwargs): - db.session.remove() + flask_db.session.remove() + + +# I'm importing it here to make sure that the flask initializes before the fastapi one +from v2.main import app as fastapi_app # noqa + + +# Middleware to check if the path exists in FastAPI +# If it does, proceed with the request +# If it doesn't, modify the request to forward to the Flask app +class PathCheckMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + + 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 + + +# Setup the pass-through to Flask app +fastapi_app.add_middleware(PathCheckMiddleware) +fastapi_app.mount("/flask", WSGIMiddleware(flask_app)) if __name__ == "__main__": - eventlet.wsgi.server(eventlet.listen(("0.0.0.0", 5000)), app, log=app.logger) + import uvicorn + + uvicorn.run(fastapi_app, host="0.0.0.0", port=5000) diff --git a/backend/v2/__init__.py b/backend/v2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/allocations/__init__.py b/backend/v2/allocations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/allocations/dependencies.py b/backend/v2/allocations/dependencies.py new file mode 100644 index 0000000000..df1ea54741 --- /dev/null +++ b/backend/v2/allocations/dependencies.py @@ -0,0 +1,45 @@ +from typing import Annotated + +from fastapi import Depends +from v2.allocations.services import Allocator +from v2.allocations.validators import SignatureVerifier +from v2.core.dependencies import GetChainSettings, GetSession +from v2.epochs.dependencies import GetEpochsSubgraph, GetOpenAllocationWindowEpochNumber +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.projects.dependencies import GetProjectsContracts +from v2.uniqueness_quotients.dependencies import GetUQScoreGetter + + +def get_signature_verifier( + session: GetSession, + epochs_subgraph: GetEpochsSubgraph, + projects_contracts: GetProjectsContracts, + settings: GetChainSettings, +) -> SignatureVerifier: + return SignatureVerifier( + session, epochs_subgraph, projects_contracts, settings.chain_id + ) + + +GetSignatureVerifier = Annotated[SignatureVerifier, Depends(get_signature_verifier)] + + +async def get_allocator( + epoch_number: GetOpenAllocationWindowEpochNumber, + session: GetSession, + signature_verifier: GetSignatureVerifier, + uq_score_getter: GetUQScoreGetter, + projects_contracts: GetProjectsContracts, + matched_rewards_estimator: GetMatchedRewardsEstimator, +) -> Allocator: + return Allocator( + session, + signature_verifier, + uq_score_getter, + projects_contracts, + matched_rewards_estimator, + epoch_number, + ) + + +GetAllocator = Annotated[Allocator, Depends(get_allocator)] diff --git a/backend/v2/allocations/repositories.py b/backend/v2/allocations/repositories.py new file mode 100644 index 0000000000..eac0d715c0 --- /dev/null +++ b/backend/v2/allocations/repositories.py @@ -0,0 +1,174 @@ +from datetime import datetime +from decimal import Decimal + +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 Numeric, cast, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload +from sqlalchemy.sql.functions import coalesce +from v2.allocations.schemas import ( + AllocationWithUserUQScore, + ProjectDonation, + UserAllocationRequest, +) +from v2.core.types import Address +from v2.users.repositories import get_user_by_address + + +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(coalesce(func.sum(cast(Allocation.amount, Numeric)), 0)) + .filter(Allocation.epoch == epoch_number) + .filter(Allocation.deleted_at.is_(None)) + ) + count = result.scalar() + + if count is None: + return 0 + + return int(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.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) + ) + + rows = result.all() + + return [ + AllocationWithUserUQScore( + projectAddress=project_address, + amount=amount, + userAddress=user_address, + userUqScore=Decimal(uq_score), + ) + for project_address, amount, user_address, uq_score in rows + ] + + +async def soft_delete_user_allocations_by_epoch( + session: AsyncSession, + user_address: Address, + 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: Address, + 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: Address, +) -> 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 + + return await session.scalar( + select(func.max(AllocationRequestDB.nonce)).filter( + AllocationRequestDB.user_id == user.id + ) + ) + + +async def get_donations_by_project( + session: AsyncSession, + 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)) + ) + + allocations = result.scalars().all() + + return [ + ProjectDonation( + amount=a.amount, + donorAddress=a.user.address, + projectAddress=a.project_address, + ) + for a in allocations + ] diff --git a/backend/v2/allocations/router.py b/backend/v2/allocations/router.py new file mode 100644 index 0000000000..dba8cc4782 --- /dev/null +++ b/backend/v2/allocations/router.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter +from v2.allocations.dependencies import GetAllocator +from v2.allocations.schemas import UserAllocationRequest, UserAllocationRequestV1 + +api = APIRouter(prefix="/allocations", tags=["Allocations"]) + + +@api.post("/allocate", status_code=201) +async def allocate( + # Component dependencies + allocator: GetAllocator, + # Request Parameters + allocation_request: UserAllocationRequestV1, +) -> None: + """ + Request an allocation for the user. + Only available during the allocation window. + """ + + # TODO: We should ideally move to the newer version of the schema as it's simpler + request = UserAllocationRequest( + userAddress=allocation_request.user_address, + allocations=allocation_request.payload.allocations, + nonce=allocation_request.payload.nonce, + signature=allocation_request.signature, + isManuallyEdited=allocation_request.is_manually_edited, + ) + + await allocator.handle(request) diff --git a/backend/v2/allocations/schemas.py b/backend/v2/allocations/schemas.py new file mode 100644 index 0000000000..983cdcb166 --- /dev/null +++ b/backend/v2/allocations/schemas.py @@ -0,0 +1,43 @@ +from decimal import Decimal + +from pydantic import Field +from v2.core.types import Address, BigInteger, OctantModel + + +class AllocationWithUserUQScore(OctantModel): + project_address: Address + amount: BigInteger + user_address: Address + user_uq_score: Decimal + + +class AllocationRequest(OctantModel): + project_address: Address = Field(..., alias="proposalAddress") + amount: BigInteger + + +class UserAllocationRequestPayloadV1(OctantModel): + allocations: list[AllocationRequest] + nonce: int + + +class UserAllocationRequestV1(OctantModel): + user_address: Address + payload: UserAllocationRequestPayloadV1 + signature: str + is_manually_edited: bool + + +class UserAllocationRequest(OctantModel): + user_address: Address + allocations: list[AllocationRequest] + nonce: int + signature: str + + is_manually_edited: bool + + +class ProjectDonation(OctantModel): + amount: BigInteger + donor_address: Address # user address + project_address: Address diff --git a/backend/v2/allocations/services.py b/backend/v2/allocations/services.py new file mode 100644 index 0000000000..04d227534e --- /dev/null +++ b/backend/v2/allocations/services.py @@ -0,0 +1,140 @@ +import asyncio +from dataclasses import dataclass + +from app import exceptions +from sqlalchemy.ext.asyncio import AsyncSession +from v2.allocations.repositories import ( + get_allocations_with_user_uqs, + soft_delete_user_allocations_by_epoch, + store_allocation_request, +) +from v2.allocations.schemas import AllocationWithUserUQScore, UserAllocationRequest +from v2.allocations.validators import SignatureVerifier +from v2.matched_rewards.services import MatchedRewardsEstimator +from v2.project_rewards.capped_quadriatic import cqf_simulate_leverage +from v2.projects.contracts import ProjectsContracts +from v2.uniqueness_quotients.dependencies import UQScoreGetter +from v2.users.repositories import get_user_by_address + + +@dataclass +class Allocator: + session: AsyncSession + signature_verifier: SignatureVerifier + uq_score_getter: UQScoreGetter + projects_contracts: ProjectsContracts + matched_rewards_estimator: MatchedRewardsEstimator + + epoch_number: int + + async def handle( + 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_contracts=self.projects_contracts, + matched_rewards_estimator=self.matched_rewards_estimator, + epoch_number=self.epoch_number, + request=request, + ) + + +async def allocate( + # Component dependencies + session: AsyncSession, + signature_verifier: SignatureVerifier, + uq_score_getter: UQScoreGetter, + projects_contracts: ProjectsContracts, + matched_rewards_estimator: MatchedRewardsEstimator, + epoch_number: int, + # Arguments + request: UserAllocationRequest, +) -> str: + # Verify the signature + await signature_verifier.verify( + epoch_number=epoch_number, + request=request, + ) + + # Get or calculate UQ score of the user + user_uq_score = await uq_score_getter.get_or_calculate( + epoch_number=epoch_number, + user_address=request.user_address, + ) + + # Calculate leverage by simulating the allocation + new_allocations = [ + AllocationWithUserUQScore( + projectAddress=a.project_address, + amount=a.amount, + userAddress=request.user_address, + userUqScore=user_uq_score, + ) + for a in request.allocations + ] + + leverage = await simulate_leverage( + session, + projects_contracts, + matched_rewards_estimator, + epoch_number, + new_allocations, + ) + + await soft_delete_user_allocations_by_epoch( + session, request.user_address, 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, + ) + + # Commit the transaction + await session.commit() + + return request.user_address + + +async def simulate_leverage( + # Component dependencies + session: AsyncSession, + projects_contracts: ProjectsContracts, + matched_rewards_estimator: MatchedRewardsEstimator, + # Arguments + epoch_number: int, + new_allocations: list[AllocationWithUserUQScore], +) -> float: + """ + Calculate leverage of the allocation made by the user. + """ + + all_projects, matched_rewards, existing_allocations = await asyncio.gather( + projects_contracts.get_project_addresses(epoch_number), + matched_rewards_estimator.get(), + get_allocations_with_user_uqs(session, epoch_number), + ) + + return cqf_simulate_leverage( + existing_allocations=existing_allocations, + new_allocations=new_allocations, + matched_rewards=matched_rewards, + project_addresses=all_projects, + ) diff --git a/backend/v2/allocations/socket.py b/backend/v2/allocations/socket.py new file mode 100644 index 0000000000..23d9dbecfc --- /dev/null +++ b/backend/v2/allocations/socket.py @@ -0,0 +1,376 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Tuple + +import socketio +from app.exceptions import OctantException +from sqlalchemy.ext.asyncio import AsyncSession +from v2.allocations.dependencies import get_allocator, get_signature_verifier +from v2.allocations.repositories import get_donations_by_project +from v2.allocations.schemas import UserAllocationRequest, UserAllocationRequestV1 +from v2.allocations.services import Allocator +from v2.core.dependencies import ( + get_chain_settings, + get_database_settings, + get_sessionmaker, + get_w3, + get_web3_provider_settings, +) +from v2.core.exceptions import AllocationWindowClosed +from v2.epochs.dependencies import ( + get_epochs_contracts, + get_epochs_settings, + get_epochs_subgraph, + get_epochs_subgraph_settings, + get_open_allocation_window_epoch_number, +) +from v2.matched_rewards.dependencies import ( + get_matched_rewards_estimator, + get_matched_rewards_estimator_settings, +) +from v2.project_rewards.dependencies import get_project_rewards_estimator +from v2.project_rewards.services import ProjectRewardsEstimator +from v2.projects.dependencies import ( + get_projects_allocation_threshold_getter, + get_projects_allocation_threshold_settings, + get_projects_contracts, + get_projects_settings, +) +from v2.projects.services import ProjectsAllocationThresholdGetter +from v2.uniqueness_quotients.dependencies import ( + get_uq_score_getter, + get_uq_score_settings, +) + + +@asynccontextmanager +async def create_dependencies_on_connect() -> AsyncGenerator[ + Tuple[AsyncSession, ProjectsAllocationThresholdGetter, ProjectRewardsEstimator], + None, +]: + """ + Create and return all service dependencies. + """ + w3 = get_w3(get_web3_provider_settings()) + epochs_contracts = get_epochs_contracts(w3, get_epochs_settings()) + + # We do not handle requests outside of pending epoch state (Allocation Window) + # This will raise an exception if the allocation window is closed and connection does not happen + epoch_number = await get_open_allocation_window_epoch_number(epochs_contracts) + + projects_contracts = get_projects_contracts(w3, get_projects_settings()) + epochs_subgraph = get_epochs_subgraph(get_epochs_subgraph_settings()) + + # For safety, we create separate sessions for each dependency + # (to avoid any potential issues with session sharing in async task context) + + sessionmaker = get_sessionmaker(get_database_settings()) + + async with ( + sessionmaker() as s1, + sessionmaker() as s2, + sessionmaker() as s3, + sessionmaker() as s4, + ): + try: + threshold_getter = get_projects_allocation_threshold_getter( + epoch_number, + s1, + projects_contracts, + get_projects_allocation_threshold_settings(), + ) + estimated_matched_rewards = await get_matched_rewards_estimator( + epoch_number, + s2, + epochs_subgraph, + get_matched_rewards_estimator_settings(), + ) + estimated_project_rewards = await get_project_rewards_estimator( + epoch_number, + s3, + projects_contracts, + estimated_matched_rewards, + ) + + # Yield the dependencies to the on_connect handler + yield (s4, threshold_getter, estimated_project_rewards) + + except Exception as e: + await cleanup_sessions(s1, s2, s3, s4) + raise e + + +@asynccontextmanager +async def create_dependencies_on_allocate() -> AsyncGenerator[ + Tuple[ + AsyncSession, + Allocator, + ProjectsAllocationThresholdGetter, + ProjectRewardsEstimator, + ], + None, +]: + """ + Create and return all service dependencies. + """ + + w3 = get_w3(get_web3_provider_settings()) + epochs_contracts = get_epochs_contracts(w3, get_epochs_settings()) + + # We do not handle requests outside of pending epoch state (Allocation Window) + # This will raise an exception if the allocation window is closed and connection does not happen + epoch_number = await get_open_allocation_window_epoch_number(epochs_contracts) + + projects_contracts = get_projects_contracts(w3, get_projects_settings()) + epochs_subgraph = get_epochs_subgraph(get_epochs_subgraph_settings()) + + # For safety, we create separate sessions for each dependency + # (to avoid any potential issues with session sharing in async task context) + sessionmaker = get_sessionmaker(get_database_settings()) + + async with ( + sessionmaker() as s1, + sessionmaker() as s2, + sessionmaker() as s3, + sessionmaker() as s4, + sessionmaker() as s5, + sessionmaker() as s6, + sessionmaker() as s7, + ): + try: + threshold_getter = get_projects_allocation_threshold_getter( + epoch_number, + s1, + projects_contracts, + get_projects_allocation_threshold_settings(), + ) + estimated_matched_rewards = await get_matched_rewards_estimator( + epoch_number, + s2, + epochs_subgraph, + get_matched_rewards_estimator_settings(), + ) + estimated_project_rewards = await get_project_rewards_estimator( + epoch_number, + s3, + projects_contracts, + estimated_matched_rewards, + ) + + signature_verifier = get_signature_verifier( + s4, + epochs_subgraph, + projects_contracts, + get_chain_settings(), + ) + + uq_score_getter = get_uq_score_getter( + s5, get_uq_score_settings(), get_chain_settings() + ) + + allocations = await get_allocator( + epoch_number, + s6, + signature_verifier, + uq_score_getter, + projects_contracts, + estimated_matched_rewards, + ) + + # Yield the dependencies to the on_allocate handler + yield ( + s7, + allocations, + threshold_getter, + estimated_project_rewards, + ) + + except Exception as e: + await cleanup_sessions(s1, s2, s3, s4, s5, s6, s7) + raise e + + +class AllocateNamespace(socketio.AsyncNamespace): + async def handle_on_connect(self, sid: str, environ: dict): + async with create_dependencies_on_connect() as ( + session, + threshold_getter, + estimated_project_rewards, + ): + logging.debug("Client connected") + + # Get the allocation threshold and send it to the client + allocation_threshold = await threshold_getter.get() + + await self.emit( + "threshold", {"threshold": str(allocation_threshold)}, to=sid + ) + + # Get the estimated project rewards and send them to the client + project_rewards = await estimated_project_rewards.get() + + await self.emit( + "project_rewards", + [ + p.model_dump(by_alias=True) + for p in project_rewards.project_fundings.values() + ], + to=sid, + ) + + for project_address in project_rewards.project_fundings: + donations = await get_donations_by_project( + session=session, + project_address=project_address, + epoch_number=estimated_project_rewards.epoch_number, + ) + + await self.emit( + "project_donors", + { + "project": project_address, + "donors": [ + { + "address": d.donor_address, + "amount": str(d.amount), + } + for d in donations + ], + }, + ) + + async def on_connect(self, sid: str, environ: dict): + try: + await self.handle_on_connect(sid, environ) + + except AllocationWindowClosed: + logging.info("Allocation window is closed, connection not established") + + except OctantException as e: + logging.error(f"OctantException({e.__class__.__name__}): {e}") + + except Exception as e: + logging.error(f"Error handling on_connect ({e.__class__.__name__}): {e}") + + async def on_disconnect(self, sid): + logging.debug("Client disconnected") + + async def handle_on_allocate(self, sid: str, data: str): + async with create_dependencies_on_allocate() as ( + session, + allocations, + threshold_getter, + estimated_project_rewards, + ): + request = from_dict(data) + + await allocations.handle(request) + + logging.debug("Allocation request handled") + + # Get the allocation threshold and send it to the client + allocation_threshold = await threshold_getter.get() + + await self.emit( + "threshold", {"threshold": str(allocation_threshold)}, to=sid + ) + + # Get the estimated project rewards and send them to the client + project_rewards = await estimated_project_rewards.get() + await self.emit( + "project_rewards", + [ + p.model_dump(by_alias=True) + for p in project_rewards.project_fundings.values() + ], + to=sid, + ) + + for project_address in project_rewards.project_fundings: + donations = await get_donations_by_project( + session=session, + project_address=project_address, + epoch_number=estimated_project_rewards.epoch_number, + ) + + await self.emit( + "project_donors", + { + "project": project_address, + "donors": [ + { + "address": d.donor_address, + "amount": str(d.amount), + } + for d in donations + ], + }, + ) + + async def on_allocate(self, sid: str, data: str): + try: + await self.handle_on_allocate(sid, data) + + except AllocationWindowClosed: + logging.info("Allocation window is closed, connection not established") + + except OctantException as e: + logging.error(f"OctantException({e.__class__.__name__}): {e.message}") + + except Exception as e: + logging.error(f"Error handling on_allocate ({e.__class__.__name__}): {e}") + + +def from_dict(data: str) -> UserAllocationRequest: + """ + Example of data: + { + "userAddress": "0x123", + "payload": { + "allocations": [ + { + "proposalAddress": "0x456", + "amount": 100 + }, + { + "proposalAddress": "0x789", + "amount": 200 + } + ], + "nonce": 1, + "signature": "0xabc" + }, + "isManuallyEdited": False + } + """ + + # TODO: maybe we can switcht to UserAllocationRequest from V1 ? + # parse the incoming data as UserAllocationRequestV1 + requestV1 = UserAllocationRequestV1.model_validate_json(data) + request = UserAllocationRequest( + userAddress=requestV1.user_address, + allocations=requestV1.payload.allocations, + nonce=requestV1.payload.nonce, + signature=requestV1.signature, + isManuallyEdited=requestV1.is_manually_edited, + ) + return request + + +async def safe_session_cleanup(session): + try: + await session.rollback() + except Exception: + # Log the rollback error, but don't raise it + logging.exception("Error during session rollback") + finally: + try: + await session.close() + except Exception: + # Log the close error, but don't raise it + logging.exception("Error during session close") + + +async def cleanup_sessions(*sessions): + await asyncio.gather(*(safe_session_cleanup(s) for s in sessions)) diff --git a/backend/v2/allocations/validators.py b/backend/v2/allocations/validators.py new file mode 100644 index 0000000000..3311b4c555 --- /dev/null +++ b/backend/v2/allocations/validators.py @@ -0,0 +1,252 @@ +import asyncio +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.allocations.repositories import get_last_allocation_request_nonce +from v2.allocations.schemas import UserAllocationRequest +from v2.core.types import Address +from v2.crypto.signatures import verify_signed_message +from v2.epochs.subgraphs import EpochsSubgraph +from v2.projects.contracts import ProjectsContracts +from v2.user_patron_mode.repositories import ( + get_budget_by_user_address_and_epoch, + user_is_patron_with_budget, +) +from web3 import AsyncWeb3 + + +@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 asyncio.gather( + verify_logic( + session=self.session, + epoch_subgraph=self.epochs_subgraph, + projects_contracts=self.projects_contracts, + epoch_number=epoch_number, + payload=request, + ), + 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() + + async def _check_database(): + await _provided_nonce_matches_expected( + session, payload.user_address, payload.nonce + ) + await _user_is_not_patron( + session, epoch_subgraph, payload.user_address, epoch_number + ) + await _user_has_budget(session, payload, epoch_number) + + await asyncio.gather( + _check_database(), + _provided_projects_are_correct(projects_contracts, epoch_number, payload), + ) + + +async def _provided_nonce_matches_expected( + # Component dependencies + session: AsyncSession, + # Arguments + user_address: Address, + nonce: int, +) -> None: + """ + Check if the nonce is as expected. + """ + # Get the next nonce + next_nonce = await get_next_user_nonce(session, user_address) + + # Check if the nonce is as expected + if nonce != next_nonce: + raise exceptions.WrongAllocationsNonce(nonce, next_nonce) + + +async def _user_is_not_patron( + # Component dependencies + session: AsyncSession, + epoch_subgraph: EpochsSubgraph, + # Arguments + user_address: Address, + epoch_number: int, +) -> None: + """ + Check if the user is not a patron. + """ + # 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, + user_address, + epoch_number, + epoch_details.finalized_timestamp.datetime(), + ) + if is_patron: + raise exceptions.NotAllowedInPatronMode(user_address) + + +async def get_next_user_nonce( + # Component dependencies + session: AsyncSession, + # Arguments + user_address: Address, +) -> 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 _provided_projects_are_correct( + # Component dependencies + projects_contracts: ProjectsContracts, + # Arguments + epoch_number: int, + payload: UserAllocationRequest, +) -> None: + """ + Check if the projects in the allocation request are correct. + """ + + # 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) + + +async def _user_has_budget( + # Component dependencies + session: AsyncSession, + # Arguments + payload: UserAllocationRequest, + epoch_number: int, +) -> None: + """ + Check if the user has enough budget for the allocation. + Check if the sum of the allocations is within the user's budget. + """ + + # 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 verify_signature( + w3: AsyncWeb3, chain_id: int, user_address: Address, 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/__init__.py b/backend/v2/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/core/contracts.py b/backend/v2/core/contracts.py new file mode 100644 index 0000000000..e73c01d134 --- /dev/null +++ b/backend/v2/core/contracts.py @@ -0,0 +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: 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 new file mode 100644 index 0000000000..3fbd366444 --- /dev/null +++ b/backend/v2/core/dependencies.py @@ -0,0 +1,146 @@ +from functools import lru_cache +from typing import Annotated, AsyncGenerator + +from fastapi import Depends +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from web3 import AsyncHTTPProvider, AsyncWeb3 +from web3.middleware import async_geth_poa_middleware + + +class OctantSettings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore", frozen=True) + + +class Web3ProviderSettings(OctantSettings): + eth_rpc_provider_url: str + + +def get_web3_provider_settings() -> Web3ProviderSettings: + return Web3ProviderSettings() # type: ignore[call-arg] + + +def get_w3( + settings: Annotated[Web3ProviderSettings, Depends(get_web3_provider_settings)] +) -> 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 + + +Web3 = Annotated[AsyncWeb3, Depends(get_w3)] + + +class DatabaseSettings(OctantSettings): + db_uri: str = Field(..., alias="db_uri") + # TODO other settings of the database + + @property + def sqlalchemy_database_uri(self) -> str: + return self.db_uri.replace("postgresql://", "postgresql+asyncpg://") + + +def get_database_settings() -> DatabaseSettings: + return DatabaseSettings() # type: ignore[call-arg] + + +@lru_cache(1) +def get_sessionmaker( + settings: Annotated[DatabaseSettings, Depends(get_database_settings)] +) -> async_sessionmaker[AsyncSession]: + engine = create_async_engine( + settings.sqlalchemy_database_uri, + echo=False, # Disable SQL query logging (for performance) + pool_size=100, # Initial pool size (default is 5) + max_overflow=10, # Extra connections if pool is exhausted + pool_timeout=30, # Timeout before giving up on a connection + pool_recycle=3600, # Recycle connections after 1 hour (for long-lived connections) + pool_pre_ping=True, # Check if the connection is alive before using it + future=True, # Use the future-facing SQLAlchemy 2.0 style + # connect_args={"options": "-c timezone=utc"} # Ensures timezone is UTC + ) + + sessionmaker = async_sessionmaker( + autocommit=False, autoflush=False, bind=engine, class_=AsyncSession + ) + + return sessionmaker + + +# @asynccontextmanager +async def get_db_session( + sessionmaker: Annotated[async_sessionmaker[AsyncSession], Depends(get_sessionmaker)] +) -> AsyncGenerator[AsyncSession, None]: + # Create an async SQLAlchemy engine + + # logging.error("Creating database engine") + + # engine = create_async_engine( + # settings.sqlalchemy_database_uri, + # echo=False, # Disable SQL query logging (for performance) + # pool_size=20, # Initial pool size (default is 5) + # max_overflow=10, # Extra connections if pool is exhausted + # pool_timeout=30, # Timeout before giving up on a connection + # pool_recycle=3600, # Recycle connections after 1 hour (for long-lived connections) + # pool_pre_ping=True, # Check if the connection is alive before using it + # future=True, # Use the future-facing SQLAlchemy 2.0 style + # # connect_args={"options": "-c timezone=utc"} # Ensures timezone is UTC + # ) + + # # Create a sessionmaker with AsyncSession class + # async_session = async_sessionmaker( + # autocommit=False, autoflush=False, bind=engine, class_=AsyncSession + # ) + + # logging.error("Opening session", async_session) + + # scoped_session = async_scoped_session(sessionmaker, scopefunc=current_task) + + # Create a new session + async with sessionmaker() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +GetSession = Annotated[AsyncSession, Depends(get_db_session, use_cache=False)] + + +class ChainSettings(OctantSettings): + chain_id: int = Field( + default=11155111, + description="The chain id to use for the signature verification.", + ) + + +def get_chain_settings() -> ChainSettings: + return ChainSettings() + + +GetChainSettings = Annotated[ChainSettings, Depends(get_chain_settings)] + + +class SocketioSettings(OctantSettings): + host: str = Field(..., alias="SOCKETIO_REDIS_HOST") + port: int = Field(..., alias="SOCKETIO_REDIS_PORT") + password: str = Field(..., alias="SOCKETIO_REDIS_PASSWORD") + db: int = Field(..., alias="SOCKETIO_REDIS_DB") + + @property + def url(self) -> str: + return f"redis://:{self.password}@{self.host}:{self.port}/{self.db}" + + +def get_socketio_settings() -> SocketioSettings: + return SocketioSettings() # type: ignore[call-arg] + + +GetSocketioSettings = Annotated[SocketioSettings, Depends(get_socketio_settings)] diff --git a/backend/v2/core/exceptions.py b/backend/v2/core/exceptions.py new file mode 100644 index 0000000000..cf58d0c51a --- /dev/null +++ b/backend/v2/core/exceptions.py @@ -0,0 +1,9 @@ +from app.exceptions import OctantException + + +class AllocationWindowClosed(OctantException): + code = 403 # Forbidden + description = "This action is available only during the allocation window." + + def __init__(self): + super().__init__(self.description, self.code) diff --git a/backend/v2/core/types.py b/backend/v2/core/types.py new file mode 100644 index 0000000000..83ae158eb3 --- /dev/null +++ b/backend/v2/core/types.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from eth_utils import to_checksum_address +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from pydantic.functional_serializers import WrapSerializer +from pydantic.functional_validators import AfterValidator + + +class OctantModel(BaseModel): + model_config = ConfigDict(frozen=True, alias_generator=to_camel) + + +# Address is a checksummed Ethereum address. +Address = Annotated[str, AfterValidator(to_checksum_address)] + +BigInteger = Annotated[ + int, AfterValidator(int), WrapSerializer(lambda x, y, z: str(x), str) +] diff --git a/backend/v2/crypto/__init__.py b/backend/v2/crypto/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/crypto/contracts.py b/backend/v2/crypto/contracts.py new file mode 100644 index 0000000000..101d1584b4 --- /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 0000000000..372cb22390 --- /dev/null +++ b/backend/v2/crypto/signatures.py @@ -0,0 +1,64 @@ +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.core.types import 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: Address, + 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: Address, 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) # type: ignore[arg-type] + return await gnosis_safe.is_valid_signature(msg_hash, signature) + except ContractLogicError: + return False + + +def _verify_eoa( + user_address: Address, 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/deposits/__init__.py b/backend/v2/deposits/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/deposits/contracts.py b/backend/v2/deposits/contracts.py new file mode 100644 index 0000000000..664eb8492b --- /dev/null +++ b/backend/v2/deposits/contracts.py @@ -0,0 +1,39 @@ +from typing import Protocol + +from v2.core.contracts import SmartContract + + +class AddressKey(Protocol): + address: str + key: str + + +class DepositsContracts(SmartContract): + async def lock(self, account: AddressKey, amount: int): + nonce = await self.w3.eth.get_transaction_count(account.address) + transaction = await self.contract.functions.lock(amount).build_transaction( + {"from": account.address, "nonce": nonce} + ) + signed_tx = self.w3.eth.account.sign_transaction(transaction, account.key) + return self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + + async def balance_of(self, owner_address: str) -> int: + return await self.contract.functions.deposits(owner_address).call() + + +DEPOSITS_ABI = [ + { + "inputs": [{"internalType": "uint256", "name": "amount", "type": "uint256"}], + "name": "lock", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{"internalType": "address", "name": "", "type": "address"}], + "name": "deposits", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, +] diff --git a/backend/v2/deposits/dependencies.py b/backend/v2/deposits/dependencies.py new file mode 100644 index 0000000000..cbe9a36500 --- /dev/null +++ b/backend/v2/deposits/dependencies.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import Depends +from v2.core.dependencies import OctantSettings, Web3 +from v2.deposits.contracts import DEPOSITS_ABI, DepositsContracts + + +class DepositsSettings(OctantSettings): + deposits_contract_address: str + + +def get_deposits_settings() -> DepositsSettings: + return DepositsSettings() # type: ignore[call-arg] + + +def get_deposits_contracts( + w3: Web3, settings: Annotated[DepositsSettings, Depends(get_deposits_settings)] +) -> DepositsContracts: + return DepositsContracts(w3, DEPOSITS_ABI, settings.deposits_contract_address) # type: ignore[arg-type] diff --git a/backend/v2/epoch_snapshots/__init__.py b/backend/v2/epoch_snapshots/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/epoch_snapshots/repositories.py b/backend/v2/epoch_snapshots/repositories.py new file mode 100644 index 0000000000..b1e3117d90 --- /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/__init__.py b/backend/v2/epochs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/epochs/contracts.py b/backend/v2/epochs/contracts.py new file mode 100644 index 0000000000..58d919369f --- /dev/null +++ b/backend/v2/epochs/contracts.py @@ -0,0 +1,151 @@ +import logging +from typing import Dict, Optional + +from v2.core.contracts import SmartContract +from web3 import exceptions + + +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() + + 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 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", + }, +] diff --git a/backend/v2/epochs/dependencies.py b/backend/v2/epochs/dependencies.py new file mode 100644 index 0000000000..3315a7e386 --- /dev/null +++ b/backend/v2/epochs/dependencies.py @@ -0,0 +1,67 @@ +from typing import Annotated + +from fastapi import Depends +from v2.core.dependencies import OctantSettings, Web3 +from v2.core.exceptions import AllocationWindowClosed +from v2.epochs.contracts import EPOCHS_ABI, EpochsContracts +from v2.epochs.subgraphs import EpochsSubgraph + + +class EpochsSettings(OctantSettings): + epochs_contract_address: str + + +def get_epochs_settings() -> EpochsSettings: + return EpochsSettings() # type: ignore[call-arg] + + +def get_epochs_contracts( + w3: Web3, settings: Annotated[EpochsSettings, Depends(get_epochs_settings)] +) -> EpochsContracts: + return EpochsContracts(w3, EPOCHS_ABI, settings.epochs_contract_address) # type: ignore[arg-type] + + +GetEpochsContracts = Annotated[ + EpochsContracts, + Depends(get_epochs_contracts), +] + + +async def get_open_allocation_window_epoch_number( + epochs_contracts: GetEpochsContracts, +) -> int: + """Returns the current epoch number only if the allocation window is open, + otherwise raises AllocationWindowClosed. + """ + + epoch_number = await epochs_contracts.get_pending_epoch() + if epoch_number is None: + raise AllocationWindowClosed() + + return epoch_number + + +GetOpenAllocationWindowEpochNumber = Annotated[ + int, + Depends(get_open_allocation_window_epoch_number), +] + + +class EpochsSubgraphSettings(OctantSettings): + subgraph_endpoint: str + + +def get_epochs_subgraph_settings() -> EpochsSubgraphSettings: + return EpochsSubgraphSettings() # type: ignore[call-arg] + + +def get_epochs_subgraph( + settings: Annotated[EpochsSubgraphSettings, Depends(get_epochs_subgraph_settings)] +) -> EpochsSubgraph: + return EpochsSubgraph(settings.subgraph_endpoint) + + +GetEpochsSubgraph = Annotated[ + EpochsSubgraph, + Depends(get_epochs_subgraph), +] diff --git a/backend/v2/epochs/subgraphs.py b/backend/v2/epochs/subgraphs.py new file mode 100644 index 0000000000..d7f8e6cf72 --- /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/glms/__init__.py b/backend/v2/glms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/glms/contracts.py b/backend/v2/glms/contracts.py new file mode 100644 index 0000000000..f5e9b26273 --- /dev/null +++ b/backend/v2/glms/contracts.py @@ -0,0 +1,84 @@ +from typing import Protocol + +from v2.core.contracts import SmartContract + + +class AddressKey(Protocol): + address: str + key: str + + +class GLMContracts(SmartContract): + # def glm_fund(self, to_address, nonce): + # transaction = self.contract.functions.transfer( + # to_address, app.config["GLM_WITHDRAWAL_AMOUNT"] + # ).build_transaction({"from": app.config["GLM_SENDER_ADDRESS"], "nonce": nonce}) + # signed_tx = self.w3.eth.account.sign_transaction( + # transaction, app.config["GLM_SENDER_PRIVATE_KEY"] + # ) + # return self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + + # def transfer(self, sender, receiver: str, amount: int): + # async def transfer(self, sender_address: str, receiver: str, amount: int): + async def transfer( + self, sender: AddressKey, receiver_address: str, amount: int + ) -> None: + nonce = await self.w3.eth.get_transaction_count(sender) + transaction = self.contract.functions.transfer( + receiver_address, amount + ).build_transaction({"from": sender.address, "nonce": nonce}) + signed_tx = self.w3.eth.account.sign_transaction(transaction, sender.key) + await self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + + async def approve(self, owner: AddressKey, benefactor_address, wad: int): + print("owner of lock: ", owner) + print("owner address: ", owner.address) + print("owner key: ", owner.key) + print("benefactor of lock: ", benefactor_address) + nonce = await self.w3.eth.get_transaction_count(owner.address) + transaction = await self.contract.functions.approve( + benefactor_address, wad + ).build_transaction({"from": owner.address, "nonce": nonce}) + signed_tx = self.w3.eth.account.sign_transaction(transaction, owner.key) + return self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + + # def balance_of(self, owner: str) -> int: + # return self.contract.functions.balanceOf(owner).call() + + +ERC20_ABI = [ + { + "inputs": [], + "name": "totalSupply", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "address", "name": "account", "type": "address"}], + "name": "balanceOf", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "to", "type": "address"}, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + ], + "name": "transfer", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "usr", "type": "address"}, + {"internalType": "uint256", "name": "wad", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + }, +] diff --git a/backend/v2/glms/dependencies.py b/backend/v2/glms/dependencies.py new file mode 100644 index 0000000000..44debdc9ce --- /dev/null +++ b/backend/v2/glms/dependencies.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import Depends +from v2.core.dependencies import OctantSettings, Web3 +from v2.glms.contracts import ERC20_ABI, GLMContracts + + +class GLMSettings(OctantSettings): + glm_contract_address: str + + +def get_glm_settings() -> GLMSettings: + return GLMSettings() # type: ignore[call-arg] + + +def get_glm_contracts( + w3: Web3, settings: Annotated[GLMSettings, Depends(get_glm_settings)] +) -> GLMContracts: + return GLMContracts(w3, ERC20_ABI, settings.glm_contract_address) # type: ignore[arg-type] diff --git a/backend/v2/main.py b/backend/v2/main.py new file mode 100644 index 0000000000..122bf435cb --- /dev/null +++ b/backend/v2/main.py @@ -0,0 +1,68 @@ +# Create FastAPI app +import logging +import os + +import redis +import socketio +from app.exceptions import OctantException +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError +from v2.allocations.router import api as allocations_api +from v2.allocations.socket import AllocateNamespace +from v2.core.dependencies import get_socketio_settings +from v2.project_rewards.router import api as project_rewards_api + +app = FastAPI() + + +@app.exception_handler(OctantException) +async def handle_octant_exception(request, ex: OctantException): + return JSONResponse( + status_code=ex.status_code, + content={"message": ex.message}, + ) + + +@app.exception_handler(SQLAlchemyError) +async def handle_sqlalchemy_exception(request, ex: SQLAlchemyError): + logging.error(f"SQLAlchemyError: {ex}") + return JSONResponse( + status_code=500, + content={"message": "Internal server error"}, + ) + + +def get_socketio_manager() -> socketio.AsyncRedisManager | None: + if os.environ.get("SOCKETIO_MANAGER_TYPE") != "redis": + logging.info("Initializing socketio manager to default in-memory manager") + return None + + settings = get_socketio_settings() + try: + # Attempt to create a Redis connection + redis_client = redis.Redis.from_url(settings.url) + # Test the connection + redis_client.ping() + # If successful, return the AsyncRedisManager + logging.info( + f"Initialized socketio manager to redis://{settings.host}:{settings.port}/{settings.db}" + ) + return socketio.AsyncRedisManager(settings.url) + except Exception as e: + logging.error(f"Failed to establish Redis connection: {str(e)}") + raise + + +mgr = get_socketio_manager() +sio = socketio.AsyncServer( + cors_allowed_origins="*", async_mode="asgi", client_manager=mgr +) +sio.register_namespace(AllocateNamespace("/")) +sio_asgi_app = socketio.ASGIApp(socketio_server=sio, other_asgi_app=app) + +app.add_route("/socket.io/", route=sio_asgi_app) +app.add_websocket_route("/socket.io/", sio_asgi_app) + +app.include_router(allocations_api) +app.include_router(project_rewards_api) diff --git a/backend/v2/matched_rewards/__init__.py b/backend/v2/matched_rewards/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/matched_rewards/dependencies.py b/backend/v2/matched_rewards/dependencies.py new file mode 100644 index 0000000000..cdafa0ed3f --- /dev/null +++ b/backend/v2/matched_rewards/dependencies.py @@ -0,0 +1,53 @@ +from decimal import Decimal +from typing import Annotated + +from fastapi import Depends +from pydantic import Field +from v2.core.dependencies import GetSession, OctantSettings +from v2.epochs.dependencies import ( + GetOpenAllocationWindowEpochNumber, + get_epochs_subgraph, +) +from v2.epochs.subgraphs import EpochsSubgraph +from v2.matched_rewards.services import MatchedRewardsEstimator + + +class MatchedRewardsEstimatorSettings(OctantSettings): + 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_matched_rewards_estimator_settings() -> MatchedRewardsEstimatorSettings: + return MatchedRewardsEstimatorSettings() + + +async def get_matched_rewards_estimator( + epoch_number: GetOpenAllocationWindowEpochNumber, + session: GetSession, + epochs_subgraph: Annotated[EpochsSubgraph, Depends(get_epochs_subgraph)], + settings: Annotated[ + MatchedRewardsEstimatorSettings, + Depends(get_matched_rewards_estimator_settings), + ], +) -> MatchedRewardsEstimator: + return MatchedRewardsEstimator( + session=session, + epochs_subgraph=epochs_subgraph, + tr_percent=settings.TR_PERCENT, + ire_percent=settings.IRE_PERCENT, + matched_rewards_percent=settings.MATCHED_REWARDS_PERCENT, + epoch_number=epoch_number, + ) + + +GetMatchedRewardsEstimator = Annotated[ + MatchedRewardsEstimator, + Depends(get_matched_rewards_estimator), +] diff --git a/backend/v2/matched_rewards/services.py b/backend/v2/matched_rewards/services.py new file mode 100644 index 0000000000..f26aa50f19 --- /dev/null +++ b/backend/v2/matched_rewards/services.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession +from v2.epoch_snapshots.repositories import get_pending_epoch_snapshot +from v2.epochs.subgraphs import EpochsSubgraph +from v2.user_patron_mode.repositories import get_patrons_rewards + + +@dataclass +class MatchedRewardsEstimator: + # Dependencies + session: AsyncSession + epochs_subgraph: EpochsSubgraph + # Parameters + tr_percent: Decimal + ire_percent: Decimal + matched_rewards_percent: Decimal + epoch_number: int + + async def get(self) -> 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=self.epoch_number, + ) + + +async def get_estimated_project_matched_rewards_pending( + # Dependencies + session: AsyncSession, + epochs_subgraph: EpochsSubgraph, + # Settings + tr_percent: Decimal, + ire_percent: Decimal, + matched_rewards_percent: Decimal, + # Arguments + epoch_number: int, +) -> int: + """ + Get the estimated matched rewards for the pending epoch. + """ + + 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( + session, epoch_details.finalized_timestamp.datetime(), epoch_number + ) + + 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, + ) + + +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) + + if ire_percent <= locked_ratio < tr_percent: + return int((tr_percent - locked_ratio) * staking_proceeds + patrons_rewards) + + return patrons_rewards diff --git a/backend/v2/project_rewards/__init__.py b/backend/v2/project_rewards/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/project_rewards/capped_quadriatic.py b/backend/v2/project_rewards/capped_quadriatic.py new file mode 100644 index 0000000000..8fa3197c94 --- /dev/null +++ b/backend/v2/project_rewards/capped_quadriatic.py @@ -0,0 +1,198 @@ +from collections import defaultdict +from decimal import Decimal +from math import sqrt +from typing import Dict, NamedTuple + +from v2.allocations.schemas import AllocationWithUserUQScore +from v2.core.types import Address +from v2.project_rewards.schemas import ProjectFundingSummary + + +class CappedQuadriaticFunding(NamedTuple): + project_fundings: dict[Address, ProjectFundingSummary] + amounts_total: Decimal # Sum of all allocation amounts for all projects + matched_total: Decimal # Sum of all matched rewards for all projects + + +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 + + project_fundings = { + project_address: ProjectFundingSummary( + address=project_address, + allocated=int(amount_by_project[project_address]), + matched=int(matched_by_project[project_address]), + ) + for project_address in project_addresses + } + + return CappedQuadriaticFunding( + project_fundings=project_fundings, + amounts_total=amounts_total, + 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[Address], + before_allocation: CappedQuadriaticFunding, + after_allocation: CappedQuadriaticFunding, +) -> 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: + if project_address in before_allocation.project_fundings: + before = Decimal( + before_allocation.project_fundings[project_address].matched + ) + else: + before = Decimal(0) + + # before = before_allocation_matched.get(project_address, 0) + after = after_allocation.project_fundings[project_address].matched + # after = after_allocation_matched[project_address] + + difference = abs(before - after) + total_difference += difference + + 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=before_allocation, + after_allocation=after_allocation, + ) + + return leverage diff --git a/backend/v2/project_rewards/dependencies.py b/backend/v2/project_rewards/dependencies.py new file mode 100644 index 0000000000..fad325071e --- /dev/null +++ b/backend/v2/project_rewards/dependencies.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from fastapi import Depends +from v2.core.dependencies import GetSession +from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber +from v2.matched_rewards.dependencies import GetMatchedRewardsEstimator +from v2.project_rewards.services import ProjectRewardsEstimator +from v2.projects.dependencies import GetProjectsContracts + + +async def get_project_rewards_estimator( + epoch_number: GetOpenAllocationWindowEpochNumber, + session: GetSession, + projects_contracts: GetProjectsContracts, + estimated_project_matched_rewards: GetMatchedRewardsEstimator, +) -> ProjectRewardsEstimator: + return ProjectRewardsEstimator( + session=session, + projects_contracts=projects_contracts, + matched_rewards_estimator=estimated_project_matched_rewards, + epoch_number=epoch_number, + ) + + +GetProjectRewardsEstimator = Annotated[ + ProjectRewardsEstimator, + Depends(get_project_rewards_estimator), +] diff --git a/backend/v2/project_rewards/router.py b/backend/v2/project_rewards/router.py new file mode 100644 index 0000000000..7a68f8e531 --- /dev/null +++ b/backend/v2/project_rewards/router.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter +from v2.project_rewards.dependencies import GetProjectRewardsEstimator +from v2.project_rewards.schemas import EstimatedProjectRewardsResponse + +api = APIRouter(prefix="/rewards", tags=["Allocations"]) + + +@api.get("/projects/estimated") +async def get_estimated_project_rewards( + project_rewards_estimator: GetProjectRewardsEstimator, +) -> EstimatedProjectRewardsResponse: + """ + Returns foreach project current allocation sum and estimated matched rewards. + + This endpoint is available only for the pending epoch state. + """ + + estimated_funding = await project_rewards_estimator.get() + + return EstimatedProjectRewardsResponse( + rewards=[f for f in estimated_funding.project_fundings.values()] + ) diff --git a/backend/v2/project_rewards/schemas.py b/backend/v2/project_rewards/schemas.py new file mode 100644 index 0000000000..5c53fce910 --- /dev/null +++ b/backend/v2/project_rewards/schemas.py @@ -0,0 +1,46 @@ +from pydantic import Field +from v2.core.types import Address, BigInteger, OctantModel + + +class ProjectFundingSummary(OctantModel): + address: Address = Field(..., description="The address of the project") + allocated: BigInteger = Field( + ..., description="Sum of all allocation amounts for the project" + ) + matched: BigInteger = Field( + ..., description="Sum of matched rewards for the project" + ) + + +class EstimatedProjectRewardsResponse(OctantModel): + rewards: list[ProjectFundingSummary] = Field( + ..., description="List of project funding summaries" + ) + + +# project_rewards = await project_rewards_estimator.get(pending_epoch_number) +# rewards = [ +# { +# "address": project_address, +# "allocated": str(project_rewards.amounts_by_project[project_address]), +# "matched": str(project_rewards.matched_by_project[project_address]), +# } +# for project_address in project_rewards.amounts_by_project.keys() +# ] + +# @ns.doc( +# description="Returns project rewards with estimated matched rewards for the pending epoch" +# ) +# @ns.response( +# 200, +# "", +# ) +# @ns.route("/projects/estimated") +# class EstimatedProjectRewards(OctantResource): +# @ns.marshal_with(projects_rewards_model) +# def get(self): +# app.logger.debug("Getting project rewards for the pending epoch") +# project_rewards = get_estimated_project_rewards().rewards +# app.logger.debug(f"Project rewards in the pending epoch: {project_rewards}") + +# return {"rewards": project_rewards} diff --git a/backend/v2/project_rewards/services.py b/backend/v2/project_rewards/services.py new file mode 100644 index 0000000000..76e6b43734 --- /dev/null +++ b/backend/v2/project_rewards/services.py @@ -0,0 +1,37 @@ +import asyncio +from dataclasses import dataclass + +from sqlalchemy.ext.asyncio import AsyncSession +from v2.allocations.repositories import get_allocations_with_user_uqs +from v2.matched_rewards.services import MatchedRewardsEstimator +from v2.project_rewards.capped_quadriatic import ( + CappedQuadriaticFunding, + capped_quadriatic_funding, +) +from v2.projects.contracts import ProjectsContracts + + +@dataclass +class ProjectRewardsEstimator: + # Dependencies + session: AsyncSession + projects_contracts: ProjectsContracts + matched_rewards_estimator: MatchedRewardsEstimator + + # Parameters + epoch_number: int + + async def get(self) -> CappedQuadriaticFunding: + # Gather all the necessary data for the calculation + all_projects, matched_rewards, allocations = await asyncio.gather( + self.projects_contracts.get_project_addresses(self.epoch_number), + self.matched_rewards_estimator.get(), + get_allocations_with_user_uqs(self.session, self.epoch_number), + ) + + # Calculate using the Capped Quadriatic Funding formula + return capped_quadriatic_funding( + project_addresses=all_projects, + allocations=allocations, + matched_rewards=matched_rewards, + ) diff --git a/backend/v2/projects/__init__.py b/backend/v2/projects/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/projects/contracts.py b/backend/v2/projects/contracts.py new file mode 100644 index 0000000000..e2ae34df55 --- /dev/null +++ b/backend/v2/projects/contracts.py @@ -0,0 +1,33 @@ +import logging + +from v2.core.contracts import SmartContract + + +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_number}" + ) + return await self.contract.functions.getProposalAddresses(epoch_number).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/dependencies.py b/backend/v2/projects/dependencies.py new file mode 100644 index 0000000000..3cf6bfef4e --- /dev/null +++ b/backend/v2/projects/dependencies.py @@ -0,0 +1,55 @@ +from typing import Annotated + +from fastapi import Depends +from pydantic import Field +from v2.core.dependencies import GetSession, OctantSettings, Web3 +from v2.epochs.dependencies import GetOpenAllocationWindowEpochNumber +from v2.projects.contracts import PROJECTS_ABI, ProjectsContracts +from v2.projects.services import ProjectsAllocationThresholdGetter + + +class ProjectsSettings(OctantSettings): + projects_contract_address: str = Field( + validation_alias="proposals_contract_address" + ) + + +def get_projects_settings() -> ProjectsSettings: + return ProjectsSettings() # type: ignore[call-arg] + + +def get_projects_contracts( + w3: Web3, settings: Annotated[ProjectsSettings, Depends(get_projects_settings)] +) -> ProjectsContracts: + return ProjectsContracts(w3, PROJECTS_ABI, settings.projects_contract_address) # type: ignore[arg-type] + + +GetProjectsContracts = Annotated[ + ProjectsContracts, + Depends(get_projects_contracts), +] + + +class ProjectsAllocationThresholdSettings(OctantSettings): + 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_settings() -> ProjectsAllocationThresholdSettings: + return ProjectsAllocationThresholdSettings() + + +def get_projects_allocation_threshold_getter( + epoch_number: GetOpenAllocationWindowEpochNumber, + session: GetSession, + projects: GetProjectsContracts, + settings: Annotated[ + ProjectsAllocationThresholdSettings, + Depends(get_projects_allocation_threshold_settings), + ], +) -> ProjectsAllocationThresholdGetter: + return ProjectsAllocationThresholdGetter( + epoch_number, session, projects, settings.project_count_multiplier + ) diff --git a/backend/v2/projects/services.py b/backend/v2/projects/services.py new file mode 100644 index 0000000000..7421f2c30f --- /dev/null +++ b/backend/v2/projects/services.py @@ -0,0 +1,57 @@ +import asyncio +from dataclasses import dataclass + +from sqlalchemy.ext.asyncio import AsyncSession +from v2.allocations.repositories import sum_allocations_by_epoch +from v2.projects.contracts import ProjectsContracts + + +@dataclass +class ProjectsAllocationThresholdGetter: + # Parameters + epoch_number: int + + # Dependencies + session: AsyncSession + projects: ProjectsContracts + project_count_multiplier: int = 1 + + async def get(self) -> int: + return await get_projects_allocation_threshold( + session=self.session, + projects=self.projects, + epoch_number=self.epoch_number, + project_count_multiplier=self.project_count_multiplier, + ) + + +async def get_projects_allocation_threshold( + # Dependencies + session: AsyncSession, + projects: ProjectsContracts, + # Arguments + epoch_number: int, + project_count_multiplier: int = 1, +) -> int: + # PROJECTS_COUNT_MULTIPLIER = 1 # TODO: from settings? + + total_allocated, project_addresses = await asyncio.gather( + sum_allocations_by_epoch(session, epoch_number), + 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 + ) diff --git a/backend/v2/uniqueness_quotients/__init__.py b/backend/v2/uniqueness_quotients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/uniqueness_quotients/dependencies.py b/backend/v2/uniqueness_quotients/dependencies.py new file mode 100644 index 0000000000..a5c110a7e2 --- /dev/null +++ b/backend/v2/uniqueness_quotients/dependencies.py @@ -0,0 +1,68 @@ +from decimal import Decimal +from typing import Annotated + +from app.constants import ( + GUEST_LIST, + TIMEOUT_LIST, + TIMEOUT_LIST_NOT_MAINNET, + UQ_THRESHOLD_MAINNET, + UQ_THRESHOLD_NOT_MAINNET, +) +from app.shared.blockchain_types import ChainTypes +from fastapi import Depends +from pydantic import Field, TypeAdapter +from v2.core.dependencies import GetChainSettings, GetSession, OctantSettings +from v2.core.types import Address +from v2.uniqueness_quotients.services import UQScoreGetter + + +class UQScoreSettings(OctantSettings): + uq_score_threshold: float = Field( + default=15.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.01"), + 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.", + ) + null_uq_score: Decimal = Field( + default=Decimal("0.0"), + description="The UQ score to be returned if the user is on the timeout list.", + ) + + +def get_uq_score_settings() -> UQScoreSettings: + return UQScoreSettings() + + +def get_uq_score_getter( + session: GetSession, + settings: Annotated[UQScoreSettings, Depends(get_uq_score_settings)], + chain_settings: GetChainSettings, +) -> UQScoreGetter: + # TODO: this should be a much nicer dependency :) + is_mainnet = chain_settings.chain_id == ChainTypes.MAINNET + + uq_threshold = UQ_THRESHOLD_MAINNET if is_mainnet else UQ_THRESHOLD_NOT_MAINNET + timeout_list = TIMEOUT_LIST if is_mainnet else TIMEOUT_LIST_NOT_MAINNET + + address_set_validator = TypeAdapter(set[Address]) + timeout_set = address_set_validator.validate_python(timeout_list) + guest_set = address_set_validator.validate_python(GUEST_LIST) + + return UQScoreGetter( + session=session, + uq_score_threshold=uq_threshold, + max_uq_score=settings.max_uq_score, + low_uq_score=settings.low_uq_score, + null_uq_score=settings.null_uq_score, + guest_list=guest_set, + timeout_list=timeout_set, + ) + + +GetUQScoreGetter = Annotated[UQScoreGetter, Depends(get_uq_score_getter)] diff --git a/backend/v2/uniqueness_quotients/repositories.py b/backend/v2/uniqueness_quotients/repositories.py new file mode 100644 index 0000000000..bb4653b6df --- /dev/null +++ b/backend/v2/uniqueness_quotients/repositories.py @@ -0,0 +1,66 @@ +from decimal import Decimal +from typing import Optional + +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 +from v2.core.types import Address +from v2.users.repositories import get_user_by_address + + +async def get_uq_score_by_user_address( + session: AsyncSession, user_address: Address, 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: Address, 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) + await session.commit() + + +async def get_gp_stamps_by_address( + session: AsyncSession, user_address: Address +) -> GPStamps | None: + """Gets the latest GitcoinPassport Stamps record for a user.""" + + user = await get_user_by_address(session, user_address) + if user is None: + return None + + result = await session.scalar( + select(GPStamps) + .filter(GPStamps.user_id == user.id) + .order_by(GPStamps.created_at.desc()) + .limit(1) + ) + + return result diff --git a/backend/v2/uniqueness_quotients/services.py b/backend/v2/uniqueness_quotients/services.py new file mode 100644 index 0000000000..9f31ad3bda --- /dev/null +++ b/backend/v2/uniqueness_quotients/services.py @@ -0,0 +1,90 @@ +from dataclasses import dataclass +from decimal import Decimal + +from app.modules.user.antisybil.core import ( + _apply_gtc_staking_stamp_nullification, + _has_guest_stamp_applied_by_gp, +) +from eth_utils import to_checksum_address +from sqlalchemy.ext.asyncio import AsyncSession +from v2.core.types import Address +from v2.uniqueness_quotients.repositories import ( + get_gp_stamps_by_address, + get_uq_score_by_user_address, + save_uq_score_for_user_address, +) + + +@dataclass +class UQScoreGetter: + session: AsyncSession + uq_score_threshold: float + max_uq_score: Decimal + low_uq_score: Decimal + null_uq_score: Decimal + guest_list: set[Address] + timeout_list: set[Address] + + async def get_or_calculate( + self, epoch_number: int, user_address: Address + ) -> 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( + self.session, user_address, epoch_number + ) + if uq_score: + return uq_score + + # Otherwise, calculate the UQ score + uq_score = await self._calculate_uq_score(user_address) + + # Save the UQ score for future reference + await save_uq_score_for_user_address( + self.session, user_address, epoch_number, uq_score + ) + + return uq_score + + async def _calculate_uq_score(self, user_address: Address) -> Decimal: + gp_score = await get_gitcoin_passport_score( + self.session, user_address, self.guest_list + ) + + if user_address in self.timeout_list: + return self.null_uq_score + + if gp_score >= self.uq_score_threshold: + return self.max_uq_score + + return self.low_uq_score + + +async def get_gitcoin_passport_score( + session: AsyncSession, user_address: Address, guest_list: set[Address] +) -> 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) + + # We have no information about the user's score + if stamps is None: + return 0.0 + + # We remove score associated with GTC staking + potential_score = _apply_gtc_staking_stamp_nullification(stamps.score, stamps) + + # If the user is in the guest list and has not been stamped by a guest list provider, increase the score by 21.0 + if user_address in guest_list and not _has_guest_stamp_applied_by_gp(stamps): + return potential_score + 21.0 + + return potential_score diff --git a/backend/v2/user_patron_mode/__init__.py b/backend/v2/user_patron_mode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/v2/user_patron_mode/repositories.py b/backend/v2/user_patron_mode/repositories.py new file mode 100644 index 0000000000..903660a5ef --- /dev/null +++ b/backend/v2/user_patron_mode/repositories.py @@ -0,0 +1,107 @@ +from datetime import datetime + +from app.infrastructure.database.models import Budget, PatronModeEvent, User +from sqlalchemy import Numeric, cast, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from v2.core.types import Address +from v2.users.repositories import get_user_by_address + + +async def get_all_patrons_at_timestamp( + session: AsyncSession, dt: datetime +) -> list[str]: + """ + Get all the user addresses that at given timestamp have patron_mode_enabled=True. + """ + + results = await session.execute( + select(PatronModeEvent.user_address) + .filter(PatronModeEvent.created_at <= dt) + .group_by(PatronModeEvent.user_address) + .having( + func.max(PatronModeEvent.created_at).filter( + PatronModeEvent.patron_mode_enabled + ) + == func.max(PatronModeEvent.created_at) + ) + ) + + return [row[0] for row in results.all()] + + +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, Numeric))) + .join(User) + .filter(User.address.in_(users_addresses), Budget.epoch == epoch_number) + ) + total_budget = result.scalar() + + if total_budget is None: + return 0 + + return int(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: Address, 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: Address, + 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 0000000000..e69de29bb2 diff --git a/backend/v2/users/repositories.py b/backend/v2/users/repositories.py new file mode 100644 index 0000000000..f103df0e6c --- /dev/null +++ b/backend/v2/users/repositories.py @@ -0,0 +1,15 @@ +from app.infrastructure.database.models import User +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from v2.core.types import Address + + +async def get_user_by_address( + session: AsyncSession, user_address: Address +) -> User | None: + """Get a user object by their address. Useful for all other operations related to a user.""" + + result = await session.scalar( + select(User).filter(User.address == user_address).limit(1) + ) + return result