diff --git a/engineapi/.gitignore b/engineapi/.gitignore new file mode 100644 index 00000000..3f25295a --- /dev/null +++ b/engineapi/.gitignore @@ -0,0 +1,174 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode + +# Envionments +.engine/ +.engineapi/ +.venv/ +.env/ + +# Environment variables +dev.env +test.env +prod.env +engineapi.env +.secrets/ diff --git a/engineapi/.isort.cfg b/engineapi/.isort.cfg new file mode 100644 index 00000000..81d54de1 --- /dev/null +++ b/engineapi/.isort.cfg @@ -0,0 +1,3 @@ +[settings] +profile = black +multi_line_output = 3 \ No newline at end of file diff --git a/engineapi/README.md b/engineapi/README.md new file mode 100644 index 00000000..77e6257c --- /dev/null +++ b/engineapi/README.md @@ -0,0 +1,43 @@ +# lootbox + +Use lootboxes in your game economy with ready to use contracts + +## Deployment + +Deployment with local signer server + +```bash +MOONSTREAM_SIGNING_SERVER_IP=127.0.0.1 ./dev.sh +``` + +## Run frontend + +Do from root directory workspace directory: + +Engine: + +Run dev + +``` +yarn workspace engine run dev +``` + +Build + +``` +yarn workspace engine run build +``` + +Player: + +Run dev + +``` +yarn workspace player run dev +``` + +Build + +``` +yarn workspace player run build +``` diff --git a/engineapi/alembic.sample.ini b/engineapi/alembic.sample.ini new file mode 100644 index 00000000..ff732def --- /dev/null +++ b/engineapi/alembic.sample.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/engineapi/alembic.sh b/engineapi/alembic.sh new file mode 100755 index 00000000..9ca8b9b8 --- /dev/null +++ b/engineapi/alembic.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +PYTHONPATH=".:$PYTHONPATH" alembic "$@" diff --git a/engineapi/alembic/README b/engineapi/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/engineapi/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/engineapi/alembic/env.py b/engineapi/alembic/env.py new file mode 100644 index 00000000..ddb3925c --- /dev/null +++ b/engineapi/alembic/env.py @@ -0,0 +1,87 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# from lootbox.models import Base as LootboxBase + +from engineapi.models import Base as EngineBase + +target_metadata = EngineBase.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + version_table="alembic_version", + version_table_schema=EngineBase.metadata.schema, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table="alembic_version", + version_table_schema=EngineBase.metadata.schema, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/engineapi/alembic/script.py.mako b/engineapi/alembic/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/engineapi/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/engineapi/alembic/versions/04e9f9125c90_initial.py b/engineapi/alembic/versions/04e9f9125c90_initial.py new file mode 100644 index 00000000..88330ce4 --- /dev/null +++ b/engineapi/alembic/versions/04e9f9125c90_initial.py @@ -0,0 +1,83 @@ +"""Initial + +Revision ID: 04e9f9125c90 +Revises: +Create Date: 2022-04-21 19:29:31.599594 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '04e9f9125c90' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('dropper_contracts', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('blockchain', sa.VARCHAR(length=128), nullable=False), + sa.Column('address', sa.VARCHAR(length=256), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_dropper_contracts')), + sa.UniqueConstraint('blockchain', 'address', name=op.f('uq_dropper_contracts_blockchain')), + sa.UniqueConstraint('id', name=op.f('uq_dropper_contracts_id')) + ) + op.create_index(op.f('ix_dropper_contracts_address'), 'dropper_contracts', ['address'], unique=False) + op.create_table('dropper_claims', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('dropper_contract_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('claim_id', sa.BigInteger(), nullable=True), + sa.Column('title', sa.VARCHAR(length=128), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('terminus_address', sa.VARCHAR(length=256), nullable=False), + sa.Column('terminus_pool_id', sa.BigInteger(), nullable=False), + sa.Column('claim_block_deadline', sa.BigInteger(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False), + sa.ForeignKeyConstraint(['dropper_contract_id'], ['dropper_contracts.id'], name=op.f('fk_dropper_claims_dropper_contract_id_dropper_contracts'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_dropper_claims')), + sa.UniqueConstraint('id', name=op.f('uq_dropper_claims_id')) + ) + op.create_index(op.f('ix_dropper_claims_terminus_address'), 'dropper_claims', ['terminus_address'], unique=False) + op.create_index(op.f('ix_dropper_claims_terminus_pool_id'), 'dropper_claims', ['terminus_pool_id'], unique=False) + op.create_table('dropper_claimants', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('dropper_claim_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('address', sa.VARCHAR(length=256), nullable=False), + sa.Column('amount', sa.BigInteger(), nullable=False), + sa.Column('added_by', sa.VARCHAR(length=256), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False), + sa.ForeignKeyConstraint(['dropper_claim_id'], ['dropper_claims.id'], name=op.f('fk_dropper_claimants_dropper_claim_id_dropper_claims'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_dropper_claimants')), + sa.UniqueConstraint('id', name=op.f('uq_dropper_claimants_id')) + ) + op.create_index(op.f('ix_dropper_claimants_added_by'), 'dropper_claimants', ['added_by'], unique=False) + op.create_index(op.f('ix_dropper_claimants_address'), 'dropper_claimants', ['address'], unique=False) + # ### end Alembic commands ### + + # Manual + op.execute("CREATE UNIQUE INDEX uq_dropper_claims_dropper_contract_id_claim_id ON dropper_claims(dropper_contract_id,claim_id) WHERE (claim_id is NOT NULL and active = true);") + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_dropper_claimants_address'), table_name='dropper_claimants') + op.drop_index(op.f('ix_dropper_claimants_added_by'), table_name='dropper_claimants') + op.drop_table('dropper_claimants') + op.drop_index(op.f('ix_dropper_claims_terminus_pool_id'), table_name='dropper_claims') + op.drop_index(op.f('ix_dropper_claims_terminus_address'), table_name='dropper_claims') + op.drop_table('dropper_claims') + op.drop_index(op.f('ix_dropper_contracts_address'), table_name='dropper_contracts') + op.drop_table('dropper_contracts') + # ### end Alembic commands ### + + # Manual + op.execute("DROP INDEX uq_dropper_claims_dropper_contract_id_claim_id") diff --git a/engineapi/alembic/versions/3f2ec6253b7e_unique_constraints_contract_metadata.py b/engineapi/alembic/versions/3f2ec6253b7e_unique_constraints_contract_metadata.py new file mode 100644 index 00000000..7731abff --- /dev/null +++ b/engineapi/alembic/versions/3f2ec6253b7e_unique_constraints_contract_metadata.py @@ -0,0 +1,80 @@ +"""Unique constraints, contract metadata + +Revision ID: 3f2ec6253b7e +Revises: 04e9f9125c90 +Create Date: 2022-04-26 04:53:05.221128 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3f2ec6253b7e" +down_revision = "04e9f9125c90" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + op.f("uq_dropper_claimants_dropper_claim_id"), + "dropper_claimants", + ["dropper_claim_id", "address"], + ) + op.create_unique_constraint( + op.f("uq_dropper_claimants_id"), "dropper_claimants", ["id"] + ) + op.alter_column( + "dropper_claims", + "terminus_address", + existing_type=sa.VARCHAR(length=256), + nullable=True, + ) + op.alter_column( + "dropper_claims", "terminus_pool_id", existing_type=sa.BIGINT(), nullable=True + ) + op.create_unique_constraint(op.f("uq_dropper_claims_id"), "dropper_claims", ["id"]) + op.add_column( + "dropper_contracts", sa.Column("title", sa.VARCHAR(length=128), nullable=True) + ) + op.add_column( + "dropper_contracts", sa.Column("description", sa.String(), nullable=True) + ) + op.add_column( + "dropper_contracts", sa.Column("image_uri", sa.String(), nullable=True) + ) + op.create_unique_constraint( + op.f("uq_dropper_contracts_id"), "dropper_contracts", ["id"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("uq_dropper_contracts_id"), "dropper_contracts", type_="unique" + ) + op.drop_column("dropper_contracts", "image_uri") + op.drop_column("dropper_contracts", "description") + op.drop_column("dropper_contracts", "title") + op.drop_constraint(op.f("uq_dropper_claims_id"), "dropper_claims", type_="unique") + op.alter_column( + "dropper_claims", "terminus_pool_id", existing_type=sa.BIGINT(), nullable=False + ) + op.alter_column( + "dropper_claims", + "terminus_address", + existing_type=sa.VARCHAR(length=256), + nullable=False, + ) + op.drop_constraint( + op.f("uq_dropper_claimants_id"), "dropper_claimants", type_="unique" + ) + op.drop_constraint( + op.f("uq_dropper_claimants_dropper_claim_id"), + "dropper_claimants", + type_="unique", + ) + # ### end Alembic commands ### diff --git a/engineapi/alembic/versions/6b45cfe1799c_add_leaderboard_table_add_.py b/engineapi/alembic/versions/6b45cfe1799c_add_leaderboard_table_add_.py new file mode 100644 index 00000000..f7f0d304 --- /dev/null +++ b/engineapi/alembic/versions/6b45cfe1799c_add_leaderboard_table_add_.py @@ -0,0 +1,92 @@ +"""Add leaderboard table add leaderboardscores + +Revision ID: 6b45cfe1799c +Revises: 3f2ec6253b7e +Create Date: 2022-05-19 21:09:02.690868 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "6b45cfe1799c" +down_revision = "3f2ec6253b7e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "leaderboards", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("title", sa.VARCHAR(length=128), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_leaderboards")), + sa.UniqueConstraint("id", name=op.f("uq_leaderboards_id")), + ) + op.create_table( + "leaderboard_scores", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("leaderboard_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("address", sa.VARCHAR(length=256), nullable=False), + sa.Column("score", sa.BigInteger(), nullable=False), + sa.Column( + "points_data", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["leaderboard_id"], + ["leaderboards.id"], + name=op.f("fk_leaderboard_scores_leaderboard_id_leaderboards"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_leaderboard_scores")), + sa.UniqueConstraint("id", name=op.f("uq_leaderboard_scores_id")), + sa.UniqueConstraint( + "leaderboard_id", + "address", + name=op.f("uq_leaderboard_scores_leaderboard_id"), + ), + ) + op.create_index( + op.f("ix_leaderboard_scores_address"), + "leaderboard_scores", + ["address"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_leaderboard_scores_address"), table_name="leaderboard_scores" + ) + op.drop_table("leaderboard_scores") + op.drop_table("leaderboards") + # ### end Alembic commands ### diff --git a/engineapi/alembic/versions/782ac8fe23c8_add_resource_id_column.py b/engineapi/alembic/versions/782ac8fe23c8_add_resource_id_column.py new file mode 100644 index 00000000..ab822abe --- /dev/null +++ b/engineapi/alembic/versions/782ac8fe23c8_add_resource_id_column.py @@ -0,0 +1,38 @@ +"""add resource_id column + +Revision ID: 782ac8fe23c8 +Revises: 815ae0983ef1 +Create Date: 2022-11-10 13:47:49.486491 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "782ac8fe23c8" +down_revision = "815ae0983ef1" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "leaderboards", + sa.Column("resource_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_index( + op.f("ix_leaderboards_resource_id"), + "leaderboards", + ["resource_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_leaderboards_resource_id"), table_name="leaderboards") + op.drop_column("leaderboards", "resource_id") + # ### end Alembic commands ### diff --git a/engineapi/alembic/versions/815ae0983ef1_add_raw_amount_column.py b/engineapi/alembic/versions/815ae0983ef1_add_raw_amount_column.py new file mode 100644 index 00000000..ada72cb2 --- /dev/null +++ b/engineapi/alembic/versions/815ae0983ef1_add_raw_amount_column.py @@ -0,0 +1,28 @@ +"""Add raw_amount column + +Revision ID: 815ae0983ef1 +Revises: f0e8022dc814 +Create Date: 2022-06-08 12:39:35.846110 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "815ae0983ef1" +down_revision = "f0e8022dc814" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "dropper_claimants", sa.Column("raw_amount", sa.String(), nullable=True) + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("dropper_claimants", "raw_amount") + # ### end Alembic commands ### diff --git a/engineapi/alembic/versions/d1be5f227664_registered_contract_and_call_requests.py b/engineapi/alembic/versions/d1be5f227664_registered_contract_and_call_requests.py new file mode 100644 index 00000000..a8874899 --- /dev/null +++ b/engineapi/alembic/versions/d1be5f227664_registered_contract_and_call_requests.py @@ -0,0 +1,154 @@ +"""registered_contracts and call_requests + +Revision ID: d1be5f227664 +Revises: 782ac8fe23c8 +Create Date: 2023-04-10 06:37:44.812202 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d1be5f227664" +down_revision = "782ac8fe23c8" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "registered_contracts", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("blockchain", sa.VARCHAR(length=128), nullable=False), + sa.Column("address", sa.VARCHAR(length=256), nullable=False), + sa.Column("contract_type", sa.VARCHAR(length=128), nullable=False), + sa.Column("title", sa.VARCHAR(length=128), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("image_uri", sa.String(), nullable=True), + sa.Column("moonstream_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_registered_contracts")), + sa.UniqueConstraint( + "blockchain", + "address", + "contract_type", + name=op.f("uq_registered_contracts_blockchain"), + ), + sa.UniqueConstraint("id", name=op.f("uq_registered_contracts_id")), + ) + op.create_index( + op.f("ix_registered_contracts_address"), + "registered_contracts", + ["address"], + unique=False, + ) + op.create_index( + op.f("ix_registered_contracts_blockchain"), + "registered_contracts", + ["blockchain"], + unique=False, + ) + op.create_index( + op.f("ix_registered_contracts_contract_type"), + "registered_contracts", + ["contract_type"], + unique=False, + ) + op.create_index( + op.f("ix_registered_contracts_moonstream_user_id"), + "registered_contracts", + ["moonstream_user_id"], + unique=False, + ) + op.create_table( + "call_requests", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "registered_contract_id", postgresql.UUID(as_uuid=True), nullable=False + ), + sa.Column("caller", sa.VARCHAR(length=256), nullable=False), + sa.Column("moonstream_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("method", sa.String(), nullable=False), + sa.Column( + "parameters", postgresql.JSONB(astext_type=sa.Text()), nullable=False + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["registered_contract_id"], + ["registered_contracts.id"], + name=op.f("fk_call_requests_registered_contract_id_registered_contracts"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_call_requests")), + sa.UniqueConstraint("id", name=op.f("uq_call_requests_id")), + ) + op.create_index( + op.f("ix_call_requests_caller"), "call_requests", ["caller"], unique=False + ) + op.create_index( + op.f("ix_call_requests_expires_at"), + "call_requests", + ["expires_at"], + unique=False, + ) + op.create_index( + op.f("ix_call_requests_method"), "call_requests", ["method"], unique=False + ) + op.create_index( + op.f("ix_call_requests_moonstream_user_id"), + "call_requests", + ["moonstream_user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_call_requests_moonstream_user_id"), table_name="call_requests" + ) + op.drop_index(op.f("ix_call_requests_method"), table_name="call_requests") + op.drop_index(op.f("ix_call_requests_expires_at"), table_name="call_requests") + op.drop_index(op.f("ix_call_requests_caller"), table_name="call_requests") + op.drop_table("call_requests") + op.drop_index( + op.f("ix_registered_contracts_moonstream_user_id"), + table_name="registered_contracts", + ) + op.drop_index( + op.f("ix_registered_contracts_contract_type"), table_name="registered_contracts" + ) + op.drop_index( + op.f("ix_registered_contracts_blockchain"), table_name="registered_contracts" + ) + op.drop_index( + op.f("ix_registered_contracts_address"), table_name="registered_contracts" + ) + op.drop_table("registered_contracts") + # ### end Alembic commands ### diff --git a/engineapi/alembic/versions/dedd8a7d0624_fix_unique_constract_on_registered_.py b/engineapi/alembic/versions/dedd8a7d0624_fix_unique_constract_on_registered_.py new file mode 100644 index 00000000..b2b77385 --- /dev/null +++ b/engineapi/alembic/versions/dedd8a7d0624_fix_unique_constract_on_registered_.py @@ -0,0 +1,44 @@ +"""Fix unique constract on registered_contracts to include moonstream_user_id + +Revision ID: dedd8a7d0624 +Revises: d1be5f227664 +Create Date: 2023-05-02 15:52:36.654980 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "dedd8a7d0624" +down_revision = "d1be5f227664" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "uq_registered_contracts_blockchain", "registered_contracts", type_="unique" + ) + op.create_unique_constraint( + op.f("uq_registered_contracts_blockchain"), + "registered_contracts", + ["blockchain", "moonstream_user_id", "address", "contract_type"], + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("uq_registered_contracts_blockchain"), + "registered_contracts", + type_="unique", + ) + op.create_unique_constraint( + "uq_registered_contracts_blockchain", + "registered_contracts", + ["blockchain", "address", "contract_type"], + ) + # ### end Alembic commands ### diff --git a/engineapi/alembic/versions/f0e8022dc814_added_column_for_signatures.py b/engineapi/alembic/versions/f0e8022dc814_added_column_for_signatures.py new file mode 100644 index 00000000..429ec946 --- /dev/null +++ b/engineapi/alembic/versions/f0e8022dc814_added_column_for_signatures.py @@ -0,0 +1,39 @@ +"""Added column for signatures + +Revision ID: f0e8022dc814 +Revises: 6b45cfe1799c +Create Date: 2022-05-24 14:19:19.022226 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f0e8022dc814" +down_revision = "6b45cfe1799c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "dropper_claimants", sa.Column("signature", sa.String(), nullable=True) + ) + op.create_index( + op.f("ix_dropper_claimants_signature"), + "dropper_claimants", + ["signature"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_dropper_claimants_signature"), table_name="dropper_claimants" + ) + op.drop_column("dropper_claimants", "signature") + # ### end Alembic commands ### diff --git a/engineapi/deploy/deploy.bash b/engineapi/deploy/deploy.bash new file mode 100755 index 00000000..689edf55 --- /dev/null +++ b/engineapi/deploy/deploy.bash @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# Deployment script + +# Colors +C_RESET='\033[0m' +C_RED='\033[1;31m' +C_GREEN='\033[1;32m' +C_YELLOW='\033[1;33m' + +# Logs +PREFIX_INFO="${C_GREEN}[INFO]${C_RESET} [$(date +%d-%m\ %T)]" +PREFIX_WARN="${C_YELLOW}[WARN]${C_RESET} [$(date +%d-%m\ %T)]" +PREFIX_CRIT="${C_RED}[CRIT]${C_RESET} [$(date +%d-%m\ %T)]" + +# Main +APP_DIR="${APP_DIR:-/home/ubuntu/api/engineapi}" +AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}" +PYTHON_ENV_DIR="${PYTHON_ENV_DIR:-/home/ubuntu/engine-env}" +PYTHON="${PYTHON_ENV_DIR}/bin/python" +PIP="${PYTHON_ENV_DIR}/bin/pip" +SCRIPT_DIR="$(realpath $(dirname $0))" +SECRETS_DIR="${SECRETS_DIR:-/home/ubuntu/engine-secrets}" +PARAMETERS_ENV_PATH="${SECRETS_DIR}/app.env" + +# API server service file +ENGINE_SERVICE_FILE="engine.service" + +set -eu + +echo +echo +echo -e "${PREFIX_INFO} Upgrading Python pip and setuptools" +"${PIP}" install --upgrade pip setuptools + +echo +echo +echo -e "${PREFIX_INFO} Installing Python dependencies" +"${PIP}" install --exists-action i -r "${APP_DIR}/requirements.txt" + +echo +echo +echo -e "${PREFIX_INFO} Install checkenv" +HOME=/home/ubuntu /usr/local/go/bin/go install github.com/bugout-dev/checkenv@latest + +echo +echo +echo -e "${PREFIX_INFO} Retrieving deployment parameters" +if [ ! -d "${SECRETS_DIR}" ]; then + mkdir "${SECRETS_DIR}" + echo -e "${PREFIX_WARN} Created new secrets directory" +fi +AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION}" /home/ubuntu/go/bin/checkenv show aws_ssm+engine:true > "${PARAMETERS_ENV_PATH}" +chmod 0640 "${PARAMETERS_ENV_PATH}" + +echo +echo +echo -e "${PREFIX_INFO} Add AWS default region to parameters" +echo "AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}" >> "${PARAMETERS_ENV_PATH}" + +echo +echo +echo -e "${PREFIX_INFO} Add instance local IP to parameters" +echo "AWS_LOCAL_IPV4=$(ec2metadata --local-ipv4)" >> "${PARAMETERS_ENV_PATH}" + +echo +echo +echo -e "${PREFIX_INFO} Replacing existing Engine API server service definition with ${ENGINE_SERVICE_FILE}" +chmod 644 "${SCRIPT_DIR}/${ENGINE_SERVICE_FILE}" +cp "${SCRIPT_DIR}/${ENGINE_SERVICE_FILE}" "/home/ubuntu/.config/systemd/user/${ENGINE_SERVICE_FILE}" +XDG_RUNTIME_DIR="/run/user/1000" systemctl --user daemon-reload +XDG_RUNTIME_DIR="/run/user/1000" systemctl --user restart --no-block "${ENGINE_SERVICE_FILE}" diff --git a/engineapi/deploy/engine.service b/engineapi/deploy/engine.service new file mode 100644 index 00000000..231017a9 --- /dev/null +++ b/engineapi/deploy/engine.service @@ -0,0 +1,16 @@ +[Unit] +Description=Engine API service +After=network.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +WorkingDirectory=/home/ubuntu/api/engineapi +EnvironmentFile=/home/ubuntu/engine-secrets/app.env +Restart=on-failure +RestartSec=15s +ExecStart=/home/ubuntu/engine-env/bin/uvicorn --proxy-headers --forwarded-allow-ips='127.0.0.1' --host 127.0.0.1 --port 7191 --workers 8 engineapi.api:app +SyslogIdentifier=engine + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/engineapi/dev.sh b/engineapi/dev.sh new file mode 100755 index 00000000..c5950e58 --- /dev/null +++ b/engineapi/dev.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +# Expects access to Python environment with the requirements +# for this project installed. +set -e + +ENGINE_HOST="${ENGINE_HOST:-127.0.0.1}" +ENGINE_PORT="${ENGINE_PORT:-7191}" +ENGINE_WORKERS="${ENGINE_WORKERS:-1}" + +uvicorn --port "$ENGINE_PORT" --host "$ENGINE_HOST" --workers "$ENGINE_WORKERS" engineapi.api:app diff --git a/engineapi/engineapi/__init__.py b/engineapi/engineapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engineapi/engineapi/abi.py b/engineapi/engineapi/abi.py new file mode 100644 index 00000000..601ee96e --- /dev/null +++ b/engineapi/engineapi/abi.py @@ -0,0 +1,78 @@ +""" +ABI utilities, because web3 doesn't do selectors well. +""" + +import glob +import json +import os +from typing import Any, Dict, List, Optional + +from web3 import Web3 + + +def abi_input_signature(input_abi: Dict[str, Any]) -> str: + """ + Stringifies a function ABI input object according to the ABI specification: + https://docs.soliditylang.org/en/v0.5.3/abi-spec.html + """ + input_type = input_abi["type"] + if input_type.startswith("tuple"): + component_types = [ + abi_input_signature(component) for component in input_abi["components"] + ] + input_type = f"({','.join(component_types)}){input_type[len('tuple'):]}" + return input_type + + +def abi_function_signature(function_abi: Dict[str, Any]) -> str: + """ + Stringifies a function ABI according to the ABI specification: + https://docs.soliditylang.org/en/v0.5.3/abi-spec.html + """ + function_name = function_abi["name"] + function_arg_types = [ + abi_input_signature(input_item) for input_item in function_abi["inputs"] + ] + function_signature = f"{function_name}({','.join(function_arg_types)})" + return function_signature + + +def encode_function_signature(function_abi: Dict[str, Any]) -> Optional[str]: + """ + Encodes the given function (from ABI) with arguments arg_1, ..., arg_n into its 4 byte signature + by calculating: + keccak256("(,...,") + + If function_abi is not actually a function ABI (detected by checking if function_abi["type"] == "function), + returns None. + """ + if function_abi["type"] != "function": + return None + function_signature = abi_function_signature(function_abi) + encoded_signature = Web3.keccak(text=function_signature)[:4] + return encoded_signature.hex() + + +def project_abis(project_dir: str) -> Dict[str, List[Dict[str, Any]]]: + """ + Load all ABIs for project contracts and return then in a dictionary keyed by contract name. + + Inputs: + - project_dir + Path to brownie project + """ + build_dir = os.path.join(project_dir, "build", "contracts") + build_files = glob.glob(os.path.join(build_dir, "*.json")) + + abis: Dict[str, List[Dict[str, Any]]] = {} + + for filepath in build_files: + contract_name, _ = os.path.splitext(os.path.basename(filepath)) + with open(filepath, "r") as ifp: + contract_artifact = json.load(ifp) + + contract_abi = contract_artifact.get("abi", []) + + abis[contract_name] = contract_abi + + return abis diff --git a/engineapi/engineapi/actions.py b/engineapi/engineapi/actions.py new file mode 100644 index 00000000..079148ba --- /dev/null +++ b/engineapi/engineapi/actions.py @@ -0,0 +1,1338 @@ +from datetime import datetime +from collections import Counter +from typing import List, Any, Optional, Dict +import uuid +import logging + +from bugout.data import BugoutResource +from eth_typing import Address +from hexbytes import HexBytes +import requests # type: ignore +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Session +from sqlalchemy import func, text, or_ +from web3 import Web3 +from web3.types import ChecksumAddress + +from .data import Score +from .contracts import Dropper_interface, ERC20_interface, Terminus_interface +from .models import ( + DropperClaimant, + DropperContract, + DropperClaim, + Leaderboard, + LeaderboardScores, +) +from . import signatures +from .settings import ( + BLOCKCHAIN_WEB3_PROVIDERS, + LEADERBOARD_RESOURCE_TYPE, + MOONSTREAM_APPLICATION_ID, + MOONSTREAM_ADMIN_ACCESS_TOKEN, + bugout_client as bc, +) + + +class AuthorizationError(Exception): + pass + + +class DropWithNotSettedBlockDeadline(Exception): + pass + + +class DublicateClaimantError(Exception): + pass + + +class DuplicateLeaderboardAddressError(Exception): + def __init__(self, message, duplicates): + super(DuplicateLeaderboardAddressError, self).__init__(message) + self.message = message + self.duplicates = duplicates + + +class LeaderboardIsEmpty(Exception): + pass + + +class LeaderboardDeleteScoresError(Exception): + pass + + +BATCH_SIGNATURE_PAGE_SIZE = 500 + +logger = logging.getLogger(__name__) + + +def create_dropper_contract( + db_session: Session, + blockchain: Optional[str], + dropper_contract_address, + title, + description, + image_uri, +): + """ + Create a new dropper contract. + """ + + dropper_contract = DropperContract( + blockchain=blockchain, + address=Web3.toChecksumAddress(dropper_contract_address), + title=title, + description=description, + image_uri=image_uri, + ) + db_session.add(dropper_contract) + db_session.commit() + return dropper_contract + + +def delete_dropper_contract( + db_session: Session, blockchain: Optional[str], dropper_contract_address +): + + dropper_contract = ( + db_session.query(DropperContract) + .filter( + DropperContract.address == Web3.toChecksumAddress(dropper_contract_address) + ) + .filter(DropperContract.blockchain == blockchain) + .one() + ) + + db_session.delete(dropper_contract) + db_session.commit() + return dropper_contract + + +def list_dropper_contracts( + db_session: Session, blockchain: Optional[str] +) -> List[Dict[str, Any]]: + """ + List all dropper contracts + """ + + dropper_contracts = [] + + dropper_contracts = db_session.query(DropperContract) + + if blockchain: + dropper_contracts = dropper_contracts.filter( + DropperContract.blockchain == blockchain + ) + + return dropper_contracts + + +def get_dropper_contract_by_id( + db_session: Session, dropper_contract_id: uuid.UUID +) -> DropperContract: + """ + Get a dropper contract by its ID + """ + query = db_session.query(DropperContract).filter( + DropperContract.id == dropper_contract_id + ) + return query.one() + + +def list_drops_terminus(db_session: Session, blockchain: Optional[str] = None): + """ + List distinct of terminus addressess + """ + + terminus = ( + db_session.query( + DropperClaim.terminus_address, + DropperClaim.terminus_pool_id, + DropperContract.blockchain, + ) + .join(DropperContract) + .filter(DropperClaim.terminus_address.isnot(None)) + .filter(DropperClaim.terminus_pool_id.isnot(None)) + ) + if blockchain: + terminus = terminus.filter(DropperContract.blockchain == blockchain) + + terminus = terminus.distinct( + DropperClaim.terminus_address, DropperClaim.terminus_pool_id + ) + + return terminus + + +def list_drops_blockchains(db_session: Session): + """ + List distinct of blockchains + """ + + blockchains = ( + db_session.query(DropperContract.blockchain) + .filter(DropperContract.blockchain.isnot(None)) + .distinct(DropperContract.blockchain) + ) + + return blockchains + + +def list_claims(db_session: Session, dropper_contract_id, active=True): + """ + List all claims + """ + + claims = ( + db_session.query( + DropperClaim.id, + DropperClaim.title, + DropperClaim.description, + DropperClaim.terminus_address, + DropperClaim.terminus_pool_id, + DropperClaim.claim_block_deadline, + ) + .filter(DropperClaim.dropper_contract_id == dropper_contract_id) + .filter(DropperClaim.active == active) + .all() + ) + + return claims + + +def delete_claim(db_session: Session, dropper_claim_id): + """ + Delete a claim + """ + + claim = ( + db_session.query(DropperClaim).filter(DropperClaim.id == dropper_claim_id).one() + ) + + db_session.delete(claim) + db_session.commit() + + return claim + + +def create_claim( + db_session: Session, + dropper_contract_id: uuid.UUID, + claim_id: Optional[int] = None, + title: Optional[str] = None, + description: Optional[str] = None, + terminus_address: Optional[ChecksumAddress] = None, + terminus_pool_id: Optional[int] = None, + claim_block_deadline: Optional[int] = None, +): + """ + Create a new dropper claim. + """ + + # get the dropper contract + + dropper_contract = ( + db_session.query(DropperContract) + .filter(DropperContract.id == dropper_contract_id) + .one() + ) + + dropper_claim = DropperClaim( + dropper_contract_id=dropper_contract.id, + claim_id=claim_id, + title=title, + description=description, + terminus_address=terminus_address, + terminus_pool_id=terminus_pool_id, + claim_block_deadline=claim_block_deadline, + ) + db_session.add(dropper_claim) + db_session.commit() + db_session.refresh(dropper_claim) # refresh the object to get the id + return dropper_claim + + +def activate_drop(db_session: Session, dropper_claim_id: uuid.UUID): + """ + Activate a claim + """ + + claim = ( + db_session.query(DropperClaim).filter(DropperClaim.id == dropper_claim_id).one() + ) + + claim.active = True + db_session.commit() + + return claim + + +def deactivate_drop(db_session: Session, dropper_claim_id: uuid.UUID): + """ + Activate a claim + """ + + claim = ( + db_session.query(DropperClaim).filter(DropperClaim.id == dropper_claim_id).one() + ) + + claim.active = False + db_session.commit() + + return claim + + +def update_drop( + db_session: Session, + dropper_claim_id: uuid.UUID, + title: Optional[str] = None, + description: Optional[str] = None, + terminus_address: Optional[str] = None, + terminus_pool_id: Optional[int] = None, + claim_block_deadline: Optional[int] = None, + claim_id: Optional[int] = None, + address: Optional[str] = None, +): + """ + Update a claim + """ + + claim = ( + db_session.query(DropperClaim).filter(DropperClaim.id == dropper_claim_id).one() + ) + + if title: + claim.title = title + if description: + claim.description = description + if terminus_address or terminus_pool_id: + ensure_dropper_contract_owner(db_session, claim.dropper_contract_id, address) + if terminus_address: + terminus_address = Web3.toChecksumAddress(terminus_address) + claim.terminus_address = terminus_address + if terminus_pool_id: + claim.terminus_pool_id = terminus_pool_id + if claim_block_deadline: + claim.claim_block_deadline = claim_block_deadline + if claim_id: + claim.claim_id = claim_id + + db_session.commit() + + return claim + + +def get_free_drop_number_in_range( + db_session: Session, dropper_contract_id: uuid.UUID, start: Optional[int], end: int +): + """ + Return list of free drops number in range + """ + + if start is None: + start = 0 + + drops = ( + db_session.query(DropperClaim) + .filter(DropperClaim.dropper_contract_id == dropper_contract_id) + .filter(DropperClaim.claim_id >= start) + .filter(DropperClaim.claim_id <= end) + .all() + ) + free_numbers = list(range(start, end + 1)) + for drop in drops: + free_numbers.remove(drop.claim_id) + return drops + + +def add_claimants(db_session: Session, dropper_claim_id, claimants, added_by): + """ + Add a claimants to a claim + """ + + # On conflict requirements https://stackoverflow.com/questions/42022362/no-unique-or-exclusion-constraint-matching-the-on-conflict + + claimant_objects = [] + + addresses = [Web3.toChecksumAddress(claimant.address) for claimant in claimants] + + if len(claimants) > len(set(addresses)): + raise DublicateClaimantError("Duplicate claimants") + + for claimant in claimants: + claimant_objects.append( + { + "dropper_claim_id": dropper_claim_id, + "address": Web3.toChecksumAddress(claimant.address), + "amount": 0, + "raw_amount": str(claimant.amount), + "added_by": added_by, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + ) + + insert_statement = insert(DropperClaimant).values(claimant_objects) + + result_stmt = insert_statement.on_conflict_do_update( + index_elements=[DropperClaimant.address, DropperClaimant.dropper_claim_id], + set_=dict( + amount=insert_statement.excluded.amount, + raw_amount=insert_statement.excluded.raw_amount, + added_by=insert_statement.excluded.added_by, + updated_at=datetime.now(), + ), + ) + db_session.execute(result_stmt) + db_session.commit() + + return claimant_objects + + +def transform_claim_amount( + db_session: Session, dropper_claim_id: uuid.UUID, db_amount: int +) -> int: + claim = ( + db_session.query( + DropperClaim.claim_id, DropperContract.address, DropperContract.blockchain + ) + .join(DropperContract, DropperContract.id == DropperClaim.dropper_contract_id) + .filter(DropperClaim.id == dropper_claim_id) + .one() + ) + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[claim.blockchain], claim.address + ) + claim_info = dropper_contract.getClaim(claim.claim_id).call() + if claim_info[0] != 20: + return db_amount + + erc20_contract = ERC20_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[claim.blockchain], claim_info[1] + ) + decimals = int(erc20_contract.decimals().call()) + + return db_amount * (10**decimals) + + +def batch_transform_claim_amounts( + db_session: Session, dropper_claim_id: uuid.UUID, db_amounts: List[int] +) -> List[int]: + claim = ( + db_session.query( + DropperClaim.claim_id, DropperContract.address, DropperContract.blockchain + ) + .join(DropperContract, DropperContract.id == DropperClaim.dropper_contract_id) + .filter(DropperClaim.id == dropper_claim_id) + .one() + ) + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[claim.blockchain], claim.address + ) + claim_info = dropper_contract.getClaim(claim.claim_id) + if claim_info[0] != 20: + return db_amounts + + erc20_contract = ERC20_interface.Contract(claim.blockchain, claim_info[1]) + decimals = int(erc20_contract.decimals().call()) + + return [db_amount * (10**decimals) for db_amount in db_amounts] + + +def get_claimants( + db_session: Session, + dropper_claim_id: uuid.UUID, + amount: Optional[int] = None, + added_by: Optional[str] = None, + address: Optional[ChecksumAddress] = None, + limit=None, + offset=None, +): + """ + Search for a claimant by address + """ + claimants_query = db_session.query( + DropperClaimant.address, + DropperClaimant.amount, + DropperClaimant.added_by, + DropperClaimant.raw_amount, + ).filter(DropperClaimant.dropper_claim_id == dropper_claim_id) + + if amount: + claimants_query = claimants_query.filter(DropperClaimant.amount == amount) + if added_by: + claimants_query = claimants_query.filter(DropperClaimant.added_by == added_by) + if address: + claimants_query = claimants_query.filter(DropperClaimant.address == address) + + if limit: + claimants_query = claimants_query.limit(limit) + + if offset: + claimants_query = claimants_query.offset(offset) + + return claimants_query.all() + + +def get_claimant(db_session: Session, dropper_claim_id, address): + """ + Search for a claimant by address + """ + + claimant_query = ( + db_session.query( + DropperClaimant.id.label("dropper_claimant_id"), + DropperClaimant.address, + DropperClaimant.amount, + DropperClaimant.raw_amount, + DropperClaimant.signature, + DropperClaim.id.label("dropper_claim_id"), + DropperClaim.claim_id, + DropperClaim.active, + DropperClaim.claim_block_deadline, + DropperClaim.title, + DropperClaim.description, + DropperContract.address.label("dropper_contract_address"), + (DropperClaim.updated_at < DropperClaimant.updated_at).label( + "is_recent_signature" + ), + DropperContract.blockchain.label("blockchain"), + ) + .join(DropperClaim, DropperClaimant.dropper_claim_id == DropperClaim.id) + .join(DropperContract, DropperClaim.dropper_contract_id == DropperContract.id) + .filter(DropperClaimant.dropper_claim_id == dropper_claim_id) + .filter(DropperClaimant.address == Web3.toChecksumAddress(address)) + ) + + return claimant_query.one() + + +def get_claimant_drops( + db_session: Session, + blockchain: str, + address, + current_block_number=None, + limit=None, + offset=None, +): + """ + Search for a claimant by address + """ + + claimant_query = ( + db_session.query( + DropperClaimant.id.label("dropper_claimant_id"), + DropperClaimant.address, + DropperClaimant.amount, + DropperClaimant.raw_amount, + DropperClaimant.signature, + DropperClaim.id.label("dropper_claim_id"), + DropperClaim.claim_id, + DropperClaim.active, + DropperClaim.claim_block_deadline, + DropperClaim.title, + DropperClaim.description, + DropperContract.address.label("dropper_contract_address"), + DropperContract.blockchain, + (DropperClaim.updated_at < DropperClaimant.updated_at).label( + "is_recent_signature" + ), + ) + .join(DropperClaim, DropperClaimant.dropper_claim_id == DropperClaim.id) + .join(DropperContract, DropperClaim.dropper_contract_id == DropperContract.id) + .filter(DropperClaim.active == True) + .filter(DropperClaimant.address == address) + .filter(DropperContract.blockchain == blockchain) + ) + + if current_block_number: + logger.info("Trying to filter block number " + str(current_block_number)) + claimant_query = claimant_query.filter( + DropperClaim.claim_block_deadline > current_block_number + ) + + claimant_query.order_by(DropperClaimant.created_at.asc()) + + if limit: + claimant_query = claimant_query.limit(limit) + + if offset: + claimant_query = claimant_query.offset(offset) + + return claimant_query.all() + + +def get_terminus_claims( + db_session: Session, + blockchain: str, + terminus_address: ChecksumAddress, + terminus_pool_id: int, + dropper_contract_address: Optional[ChecksumAddress] = None, + active: Optional[bool] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, +): + """ + Search for a claimant by address + """ + + query = ( + db_session.query( + DropperClaim.id, + DropperClaim.title, + DropperClaim.description, + DropperClaim.terminus_address, + DropperClaim.terminus_pool_id, + DropperClaim.claim_block_deadline, + DropperClaim.claim_id, + DropperClaim.active, + DropperContract.address.label("dropper_contract_address"), + ) + .join(DropperContract) + .filter(DropperClaim.terminus_address == terminus_address) + .filter(DropperClaim.terminus_pool_id == terminus_pool_id) + .filter(DropperContract.blockchain == blockchain) + ) + + if dropper_contract_address: + query = query.filter(DropperContract.address == dropper_contract_address) + + if active: + query = query.filter(DropperClaim.active == active) + + # TODO: add ordering in all pagination queries + query = query.order_by(DropperClaim.created_at.asc()) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query + + +def get_drop(db_session: Session, dropper_claim_id: uuid.UUID): + """ + Return particular drop + """ + drop = ( + db_session.query(DropperClaim).filter(DropperClaim.id == dropper_claim_id).one() + ) + return drop + + +def get_claims( + db_session: Session, + blockchain: str, + dropper_contract_address: Optional[ChecksumAddress] = None, + claimant_address: Optional[ChecksumAddress] = None, + terminus_address: Optional[ChecksumAddress] = None, + terminus_pool_id: Optional[int] = None, + active: Optional[bool] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, +): + """ + Search for a claimant by address + """ + + query = ( + db_session.query( + DropperClaim.id, + DropperClaim.title, + DropperClaim.description, + DropperClaim.terminus_address, + DropperClaim.terminus_pool_id, + DropperClaim.claim_block_deadline, + DropperClaim.claim_id, + DropperClaim.active, + DropperClaimant.amount, + DropperContract.address.label("dropper_contract_address"), + ) + .join(DropperContract) + .join(DropperClaimant) + .filter(DropperContract.blockchain == blockchain) + ) + + if dropper_contract_address: + query = query.filter(DropperContract.address == dropper_contract_address) + + if claimant_address: + query = query.filter(DropperClaimant.address == claimant_address) + + if terminus_address: + query = query.filter(DropperClaim.terminus_address == terminus_address) + + if terminus_pool_id: + query = query.filter(DropperClaim.terminus_pool_id == terminus_pool_id) + + if active: + query = query.filter(DropperClaim.active == active) + + query = query.order_by(DropperClaim.created_at.asc()) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query + + +def get_drops( + db_session: Session, + blockchain: str, + dropper_contract_address: Optional[ChecksumAddress] = None, + drop_number: Optional[int] = None, + terminus_address: Optional[ChecksumAddress] = None, + terminus_pool_id: Optional[int] = None, + active: Optional[bool] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, +): + """ + Get drops + """ + + query = ( + db_session.query( + DropperClaim.id, + DropperClaim.title, + DropperClaim.description, + DropperClaim.terminus_address, + DropperClaim.terminus_pool_id, + DropperClaim.claim_block_deadline, + DropperClaim.claim_id.label("drop_number"), + DropperClaim.active, + DropperContract.address.label("dropper_contract_address"), + ) + .join(DropperContract) + .filter(DropperContract.blockchain == blockchain) + ) + + if dropper_contract_address: + query = query.filter(DropperContract.address == dropper_contract_address) + + if drop_number: + query = query.filter(DropperClaim.claim_id == drop_number) + + if terminus_address: + query = query.filter(DropperClaim.terminus_address == terminus_address) + + if terminus_pool_id: + query = query.filter(DropperClaim.terminus_pool_id == terminus_pool_id) + + if active: + query = query.filter(DropperClaim.active == active) + + query = query.order_by(DropperClaim.created_at.desc()) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query + + +def get_claim_admin_pool( + db_session: Session, + dropper_claim_id: uuid.UUID, +) -> Any: + """ + Search for a claimant by address + """ + + query = ( + db_session.query( + DropperContract.blockchain, + DropperClaim.terminus_address, + DropperClaim.terminus_pool_id, + ) + .join(DropperContract) + .filter(DropperClaim.id == dropper_claim_id) + ) + return query.one() + + +def ensure_admin_token_holder( + db_session: Session, dropper_claim_id: uuid.UUID, address: ChecksumAddress +) -> bool: + blockchain, terminus_address, terminus_pool_id = get_claim_admin_pool( + db_session=db_session, dropper_claim_id=dropper_claim_id + ) + terminus = Terminus_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[blockchain], terminus_address + ) + balance = terminus.balanceOf(address, terminus_pool_id).call() + if balance == 0: + raise AuthorizationError( + f"Address has insufficient balance in Terminus pool: address={address}, blockchain={blockchain}, terminus_address={terminus_address}, terminus_pool_id={terminus_pool_id}" + ) + return True + + +def ensure_dropper_contract_owner( + db_session: Session, dropper_contract_id: uuid.UUID, address: ChecksumAddress +) -> bool: + dropper_contract_info = get_dropper_contract_by_id( + db_session=db_session, dropper_contract_id=dropper_contract_id + ) + dropper = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[dropper_contract_info.blockchain], + dropper_contract_info.address, + ) + dropper_owner_address = dropper.owner().call() + if address != Web3.toChecksumAddress(dropper_owner_address): + raise AuthorizationError( + f"Given address is not the owner of the given dropper contract: address={address}, blockchain={dropper_contract_info.blockchain}, dropper_address={dropper_contract_info.address}" + ) + return True + + +def delete_claimants(db_session: Session, dropper_claim_id, addresses): + """ + Delete all claimants for a claim + """ + + normalize_addresses = [Web3.toChecksumAddress(address) for address in addresses] + + was_deleted = [] + deleted_addresses = ( + db_session.query(DropperClaimant) + .filter(DropperClaimant.dropper_claim_id == dropper_claim_id) + .filter(DropperClaimant.address.in_(normalize_addresses)) + ) + for deleted_address in deleted_addresses: + was_deleted.append(deleted_address.address) + db_session.delete(deleted_address) + + db_session.commit() + + return was_deleted + + +def refetch_drop_signatures( + db_session: Session, dropper_claim_id: uuid.UUID, added_by: str +): + """ + Refetch signatures for drop + """ + + claim = ( + db_session.query( + DropperClaim.claim_id, + DropperClaim.claim_block_deadline, + DropperContract.address, + DropperContract.blockchain, + ) + .join(DropperContract, DropperClaim.dropper_contract_id == DropperContract.id) + .filter(DropperClaim.id == dropper_claim_id) + ).one() + + if claim.claim_block_deadline is None: + raise DropWithNotSettedBlockDeadline( + f"Claim block deadline is not set for dropper claim: {dropper_claim_id}" + ) + + outdated_signatures = ( + db_session.query( + DropperClaim.claim_id, + DropperClaimant.address, + DropperClaimant.amount, + ) + .join(DropperClaimant, DropperClaimant.dropper_claim_id == DropperClaim.id) + .filter(DropperClaim.id == dropper_claim_id) + .filter( + or_( + DropperClaimant.updated_at < DropperClaim.updated_at, + DropperClaimant.signature == None, + ) + ) + ).limit(BATCH_SIGNATURE_PAGE_SIZE) + + current_offset = 0 + + users_hashes = {} + users_amount = {} + hashes_signature = {} + + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[claim.blockchain], claim.address + ) + + while True: + signature_requests = [] + + page = outdated_signatures.offset(current_offset).all() + + claim_amounts = [outdated_signature.amount for outdated_signature in page] + + transformed_claim_amounts = batch_transform_claim_amounts( + db_session, dropper_claim_id, claim_amounts + ) + + for outdated_signature, transformed_claim_amount in zip( + page, transformed_claim_amounts + ): + + message_hash_raw = dropper_contract.claimMessageHash( + claim.claim_id, + outdated_signature.address, + claim.claim_block_deadline, + int(transformed_claim_amount), + ).call() + message_hash = HexBytes(message_hash_raw).hex() + signature_requests.append(message_hash) + users_hashes[outdated_signature.address] = message_hash + users_amount[outdated_signature.address] = outdated_signature.amount + + message_hashes = [signature_request for signature_request in signature_requests] + + signed_messages = signatures.DROP_SIGNER.batch_sign_message(message_hashes) + + hashes_signature.update(signed_messages) + + if len(page) == 0: + break + + current_offset += BATCH_SIGNATURE_PAGE_SIZE + + claimant_objects = [] + + for address, hash in users_hashes.items(): + claimant_objects.append( + { + "dropper_claim_id": dropper_claim_id, + "address": address, + "signature": hashes_signature[hash], + "amount": users_amount[address], + "added_by": added_by, + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + ) + + insert_statement = insert(DropperClaimant).values(claimant_objects) + + result_stmt = insert_statement.on_conflict_do_update( + index_elements=[DropperClaimant.address, DropperClaimant.dropper_claim_id], + set_=dict( + signature=insert_statement.excluded.signature, + updated_at=datetime.now(), + ), + ) + db_session.execute(result_stmt) + db_session.commit() + + return claimant_objects + + +def get_leaderboard_total_count(db_session: Session, leaderboard_id): + """ + Get the total number of claimants in the leaderboard + """ + return ( + db_session.query(LeaderboardScores) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + .count() + ) + + +def get_position( + db_session: Session, leaderboard_id, address, window_size, limit: int, offset: int +): + """ + + Return position by address with window size + """ + query = db_session.query( + LeaderboardScores.address, + LeaderboardScores.score, + LeaderboardScores.points_data.label("points_data"), + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + func.row_number().over(order_by=LeaderboardScores.score.desc()).label("number"), + ).filter(LeaderboardScores.leaderboard_id == leaderboard_id) + + ranked_leaderboard = query.cte(name="ranked_leaderboard") + + query = db_session.query( + ranked_leaderboard.c.address, + ranked_leaderboard.c.score, + ranked_leaderboard.c.rank, + ranked_leaderboard.c.number, + ).filter( + ranked_leaderboard.c.address == address, + ) + + my_position = query.cte(name="my_position") + + # get the position with the window size + + query = db_session.query( + ranked_leaderboard.c.address, + ranked_leaderboard.c.score, + ranked_leaderboard.c.rank, + ranked_leaderboard.c.number, + ranked_leaderboard.c.points_data, + ).filter( + ranked_leaderboard.c.number.between( # taking off my hat! + my_position.c.number - window_size, + my_position.c.number + window_size, + ) + ) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query.all() + + +def get_leaderboard_positions( + db_session: Session, leaderboard_id, limit: int, offset: int +): + """ + Get the leaderboard positions + """ + query = ( + db_session.query( + LeaderboardScores.id, + LeaderboardScores.address, + LeaderboardScores.score, + LeaderboardScores.points_data, + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + .order_by(text("rank asc, id asc")) + ) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query + + +def get_qurtiles(db_session: Session, leaderboard_id): + """ + Get the leaderboard qurtiles + https://docs.sqlalchemy.org/en/14/core/functions.html#sqlalchemy.sql.functions.percentile_disc + """ + + query = db_session.query( + LeaderboardScores.address, + LeaderboardScores.score, + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ).filter(LeaderboardScores.leaderboard_id == leaderboard_id) + + ranked_leaderboard = query.cte(name="ranked_leaderboard") + + current_count = db_session.query(ranked_leaderboard).count() + + if current_count == 0: + raise LeaderboardIsEmpty(f"Leaderboard {leaderboard_id} is empty") + + index_75 = int(current_count / 4) + + index_50 = int(current_count / 2) + + index_25 = int(current_count * 3 / 4) + + q1 = db_session.query(ranked_leaderboard).limit(1).offset(index_25).first() + + q2 = db_session.query(ranked_leaderboard).limit(1).offset(index_50).first() + + q3 = db_session.query(ranked_leaderboard).limit(1).offset(index_75).first() + + return q1, q2, q3 + + +def get_ranks(db_session: Session, leaderboard_id): + """ + Get the leaderboard rank buckets(rank, size, score) + """ + query = db_session.query( + LeaderboardScores.id, + LeaderboardScores.address, + LeaderboardScores.score, + LeaderboardScores.points_data, + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ).filter(LeaderboardScores.leaderboard_id == leaderboard_id) + + ranked_leaderboard = query.cte(name="ranked_leaderboard") + + ranks = db_session.query( + ranked_leaderboard.c.rank, + func.count(ranked_leaderboard.c.id).label("size"), + ranked_leaderboard.c.score, + ).group_by(ranked_leaderboard.c.rank, ranked_leaderboard.c.score) + return ranks + + +def get_rank( + db_session: Session, + leaderboard_id: uuid.UUID, + rank: int, + limit: Optional[int] = None, + offset: Optional[int] = None, +): + """ + Get bucket in leaderboard by rank + """ + query = ( + db_session.query( + LeaderboardScores.id, + LeaderboardScores.address, + LeaderboardScores.score, + LeaderboardScores.points_data, + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + .order_by(text("rank asc, id asc")) + ) + + ranked_leaderboard = query.cte(name="ranked_leaderboard") + + positions = db_session.query(ranked_leaderboard).filter( + ranked_leaderboard.c.rank == rank + ) + + if limit: + positions = positions.limit(limit) + + if offset: + positions = positions.offset(offset) + + return positions + + +def create_leaderboard(db_session: Session, title: str, description: str): + """ + Create a leaderboard + """ + + leaderboard = Leaderboard(title=title, description=description) + db_session.add(leaderboard) + db_session.commit() + + return leaderboard.id + + +def get_leaderboard_by_id(db_session: Session, leaderboard_id): + """ + Get the leaderboard by id + """ + return db_session.query(Leaderboard).filter(Leaderboard.id == leaderboard_id).one() + + +def get_leaderboard_by_title(db_session: Session, title): + """ + Get the leaderboard by title + """ + return db_session.query(Leaderboard).filter(Leaderboard.title == title).one() + + +def list_leaderboards(db_session: Session, limit: int, offset: int): + """ + List all leaderboards + """ + query = db_session.query(Leaderboard.id, Leaderboard.title, Leaderboard.description) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query.all() + + +def add_scores( + db_session: Session, + leaderboard_id: uuid.UUID, + scores: List[Score], + overwrite: bool = False, + normalize_addresses: bool = True, +): + """ + Add scores to the leaderboard + """ + + leaderboard_scores = [] + + normalizer_fn = Web3.toChecksumAddress + if not normalize_addresses: + normalizer_fn = lambda x: x # type: ignore + + addresses = [score.address for score in scores] + + if len(addresses) != len(set(addresses)): + + duplicates = [key for key, value in Counter(addresses).items() if value > 1] + + raise DuplicateLeaderboardAddressError("Dublicated addresses", duplicates) + + if overwrite: + db_session.query(LeaderboardScores).filter( + LeaderboardScores.leaderboard_id == leaderboard_id + ).delete() + try: + db_session.commit() + except: + db_session.rollback() + raise LeaderboardDeleteScoresError("Error deleting leaderboard scores") + + for score in scores: + leaderboard_scores.append( + { + "leaderboard_id": leaderboard_id, + "address": normalizer_fn(score.address), + "score": score.score, + "points_data": score.points_data, + } + ) + + insert_statement = insert(LeaderboardScores).values(leaderboard_scores) + + result_stmt = insert_statement.on_conflict_do_update( + index_elements=[LeaderboardScores.address, LeaderboardScores.leaderboard_id], + set_=dict( + score=insert_statement.excluded.score, + points_data=insert_statement.excluded.points_data, + updated_at=datetime.now(), + ), + ) + try: + db_session.execute(result_stmt) + db_session.commit() + except: + db_session.rollback() + + return leaderboard_scores + + +# leadrboard access actions + + +def create_leaderboard_resource( + leaderboard_id: uuid.UUID, + token: Optional[uuid.UUID] = None, +) -> BugoutResource: + + resource_data: Dict[str, Any] = { + "type": LEADERBOARD_RESOURCE_TYPE, + "leaderboard_id": leaderboard_id, + } + + if token is None: + token = MOONSTREAM_ADMIN_ACCESS_TOKEN + + resource = bc.create_resource( + token=MOONSTREAM_ADMIN_ACCESS_TOKEN, + application_id=MOONSTREAM_APPLICATION_ID, + resource_data=resource_data, + timeout=10, + ) + return resource + + +def assign_resource( + db_session: Session, + leaderboard_id: uuid.UUID, + resource_id: Optional[uuid.UUID] = None, +): + + """ + Assign a resource handler to a leaderboard + """ + + leaderboard = ( + db_session.query(Leaderboard).filter(Leaderboard.id == leaderboard_id).one() + ) + + if leaderboard.resource_id is not None: + + raise Exception("Leaderboard already has a resource") + + if resource_id is not None: + leaderboard.resource_id = resource_id + else: + # Create resource via admin token + + resource = create_leaderboard_resource( + leaderboard_id=leaderboard_id, + ) + + leaderboard.resource_id = resource.id + + db_session.commit() + db_session.flush() + + return leaderboard.resource_id + + +def list_leaderboards_resources( + db_session: Session, +): + + """ + List all leaderboards resources + """ + + query = db_session.query(Leaderboard.id, Leaderboard.title, Leaderboard.resource_id) + + return query.all() + + +def revoke_resource(db_session: Session, leaderboard_id: uuid.UUID): + + """ + Revoke a resource handler to a leaderboard + """ + + # TODO(ANDREY): Delete resource via admin token + + leaderboard = ( + db_session.query(Leaderboard).filter(Leaderboard.id == leaderboard_id).one() + ) + + if leaderboard.resource_id is None: + + raise Exception("Leaderboard does not have a resource") + + leaderboard.resource_id = None + + db_session.commit() + db_session.flush() + + return leaderboard.resource_id + + +def check_leaderboard_resource_permissions( + db_session: Session, leaderboard_id: uuid.UUID, token: uuid.UUID +): + """ + Check if the user has permissions to access the leaderboard + """ + leaderboard = ( + db_session.query(Leaderboard).filter(Leaderboard.id == leaderboard_id).one() + ) + + permission_url = f"{bc.brood_url}/resources/{leaderboard.resource_id}/holders" + headers = { + "Authorization": f"Bearer {token}", + } + # If user don't have at least read permission return 404 + result = requests.get(url=permission_url, headers=headers, timeout=10) + + if result.status_code == 200: + return True + + return False diff --git a/engineapi/engineapi/api.py b/engineapi/engineapi/api.py new file mode 100644 index 00000000..39aba830 --- /dev/null +++ b/engineapi/engineapi/api.py @@ -0,0 +1,65 @@ +""" +Lootbox API. +""" +import logging +import time + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from . import data +from .settings import ( + ORIGINS, +) +from .routes.dropper import app as dropper_app +from .routes.leaderboard import app as leaderboard_app +from .routes.admin import app as admin_app +from .routes.play import app as play_app +from .routes.metatx import app as metatx_app +from .version import VERSION + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +tags_metadata = [{"name": "time", "description": "Server timestamp endpoints."}] + + +app = FastAPI( + title=f"Engine HTTP API", + description="Engine API endpoints.", + version=VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/ping", response_model=data.PingResponse) +async def ping_handler() -> data.PingResponse: + """ + Check server status. + """ + return data.PingResponse(status="ok") + + +@app.get("/now", tags=["time"]) +async def now_handler() -> data.NowResponse: + """ + Get server current time. + """ + return data.NowResponse(epoch_time=time.time()) + + +app.mount("/leaderboard", leaderboard_app) +app.mount("/drops", dropper_app) +app.mount("/admin", admin_app) +app.mount("/play", play_app) +app.mount("/metatx", metatx_app) diff --git a/engineapi/engineapi/auth.py b/engineapi/engineapi/auth.py new file mode 100644 index 00000000..3fb05dd4 --- /dev/null +++ b/engineapi/engineapi/auth.py @@ -0,0 +1,180 @@ +""" +Login functionality for Moonstream Engine. + +Login flow relies on an Authorization header passed to Moonstream Engine of the form: +Authorization: moonstream + +The schema for the JSON object will be as follows: +{ + "address": "
", + "deadline": , + "signature": "" +} + +Authorization messages will be generated pursuant to EIP712 using the following parameters: +Domain separator - name: MoonstreamAuthorization, version: +Fields - address ("address" type), deadline: ("uint256" type) +""" +import argparse +import base64 +import json +import time +from typing import Any, cast, Dict + +from eip712.messages import EIP712Message, _hash_eip191_message +from eth_account import Account +from eth_account._utils.signing import sign_message_hash +import eth_keys +from hexbytes import HexBytes +from web3 import Web3 + + +AUTH_PAYLOAD_NAME = "MoonstreamAuthorization" +AUTH_VERSION = "1" + +# By default, authorizations will remain active for 24 hours. +DEFAULT_INTERVAL = 60 * 60 * 24 + + +class MoonstreamAuthorizationVerificationError(Exception): + """ + Raised when invalid signer is provided. + """ + + +class MoonstreamAuthorizationExpired(Exception): + """ + Raised when signature is expired by time. + """ + + +class MoonstreamAuthorization(EIP712Message): + _name_: "string" + _version_: "string" + + address: "address" + deadline: "uint256" + + +def sign_message(message_hash_bytes: HexBytes, private_key: HexBytes) -> HexBytes: + + eth_private_key = eth_keys.keys.PrivateKey(private_key) + _, _, _, signed_message_bytes = sign_message_hash( + eth_private_key, message_hash_bytes + ) + return signed_message_bytes + + +def authorize(deadline: int, address: str, private_key: HexBytes) -> Dict[str, Any]: + message = MoonstreamAuthorization( + _name_=AUTH_PAYLOAD_NAME, + _version_=AUTH_VERSION, + address=address, + deadline=deadline, + ) + + msg_hash_bytes = HexBytes(_hash_eip191_message(message.signable_message)) + + signed_message = sign_message(msg_hash_bytes, private_key) + + api_payload: Dict[str, Any] = { + "address": address, + "deadline": deadline, + "signed_message": signed_message.hex(), + } + + return api_payload + + +def verify(authorization_payload: Dict[str, Any]) -> bool: + """ + Verifies provided signature signer by correct address. + """ + time_now = int(time.time()) + web3_client = Web3() + address = Web3.toChecksumAddress(cast(str, authorization_payload["address"])) + deadline = cast(int, authorization_payload["deadline"]) + signature = cast(str, authorization_payload["signed_message"]) + + message = MoonstreamAuthorization( + _name_=AUTH_PAYLOAD_NAME, + _version_=AUTH_VERSION, + address=address, + deadline=deadline, + ) + + signer_address = web3_client.eth.account.recover_message( + message.signable_message, signature=signature + ) + if signer_address != address: + raise MoonstreamAuthorizationVerificationError("Invalid signer") + + if deadline < time_now: + raise MoonstreamAuthorizationExpired("Deadline exceeded") + + return True + + +def decrypt_keystore(keystore_path: str, password: str) -> HexBytes: + with open(keystore_path) as keystore_file: + keystore_data = json.load(keystore_file) + return keystore_data["address"], Account.decrypt(keystore_data, password) + + +def handle_authorize(args: argparse.Namespace) -> None: + address, private_key = decrypt_keystore(args.signer, args.password) + authorization = authorize(args.deadline, address, private_key) + print(json.dumps(authorization)) + + +def handle_verify(args: argparse.Namespace) -> None: + payload_json = base64.decodebytes(args.payload).decode("utf-8") + payload = json.loads(payload_json) + verify(payload) + print("Verified!") + + +def generate_cli() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Moonstream Engine authorization module" + ) + subcommands = parser.add_subparsers() + + authorize_parser = subcommands.add_parser("authorize") + authorize_parser.add_argument( + "-t", + "--deadline", + type=int, + default=int(time.time()) + DEFAULT_INTERVAL, + help="Authorization deadline (seconds since epoch timestamp).", + ) + authorize_parser.add_argument( + "-s", + "--signer", + required=True, + help="Path to signer keyfile (or brownie account name).", + ) + authorize_parser.add_argument( + "-p", + "--password", + required=False, + help="(Optional) password for signing account. If you don't provide it here, you will be prompte for it.", + ) + authorize_parser.set_defaults(func=handle_authorize) + + verify_parser = subcommands.add_parser("verify") + verify_parser.add_argument( + "--payload", + type=lambda s: s.encode(), + required=True, + help="Base64-encoded payload to verify", + ) + verify_parser.set_defaults(func=handle_verify) + + return parser + + +if __name__ == "__main__": + parser = generate_cli() + args = parser.parse_args() + args.func(args) diff --git a/engineapi/engineapi/cli.py b/engineapi/engineapi/cli.py new file mode 100644 index 00000000..6d7814c9 --- /dev/null +++ b/engineapi/engineapi/cli.py @@ -0,0 +1,925 @@ +import argparse +import csv +import getpass +import json +import logging +from uuid import UUID + +from engineapi.models import Leaderboard + +from . import actions +from . import db +from . import signatures +from . import data +from . import auth +from . import contracts_actions + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def signing_server_list_handler(args: argparse.Namespace) -> None: + try: + instances = signatures.list_signing_instances( + signing_instances=[] if args.instance is None else [args.instance] + ) + except Exception as err: + logger.error(f"Unhandled /list exception: {err}") + return + + print(data.SignerListResponse(instances=instances).json()) + + +def signing_server_wakeup_handler(args: argparse.Namespace) -> None: + try: + run_instances = signatures.wakeup_signing_instances( + run_confirmed=args.confirmed, dry_run=args.dry_run + ) + except signatures.AWSRunInstancesFail: + return + except Exception as err: + logger.error(f"Unhandled /wakeup exception: {err}") + return + + print(data.SignerWakeupResponse(instances=run_instances).json()) + + +def signing_server_sleep_handler(args: argparse.Namespace) -> None: + try: + terminated_instances = signatures.sleep_signing_instances( + signing_instances=[args.instance], + termination_confirmed=args.confirmed, + dry_run=args.dry_run, + ) + except signatures.AWSDescribeInstancesFail: + return + except signatures.SigningInstancesNotFound: + return + except signatures.SigningInstancesTerminationLimitExceeded: + return + except signatures.AWSTerminateInstancesFail: + return + except Exception as err: + logger.error(f"Unhandled /sleep exception: {err}") + return + + print(data.SignerSleepResponse(instances=list(terminated_instances)).json()) + + +def create_dropper_contract_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + created_contract = actions.create_dropper_contract( + db_session=db_session, + blockchain=args.blockchain, + dropper_contract_address=args.address, + title=args.title, + description=args.description, + image_uri=args.image_uri, + ) + except Exception as err: + logger.error(f"Unhandled /create_dropper_contract exception: {err}") + return + print(created_contract) + + +def delete_dropper_contract_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + removed_contract = actions.delete_dropper_contract( + db_session=db_session, + blockchain=args.blockchain, + dropper_contract_address=args.address, + ) + except Exception as err: + logger.error(f"Unhandled /delete_dropper_contract exception: {err}") + return + print(removed_contract) + + +def list_dropper_contracts_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + results = actions.list_dropper_contracts( + db_session=db_session, blockchain=args.blockchain + ) + except Exception as err: + logger.error(f"Unhandled /list_dropper_contracts exception: {err}") + return + print( + "\n".join( + [ + data.DropperContractResponse( + id=result.id, + blockchain=result.blockchain, + address=result.address, + title=result.title, + description=result.description, + image_uri=result.image_uri, + ).json() + for result in results + ] + ) + ) + + +def dropper_create_drop_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + created_claim = actions.create_claim( + db_session=db_session, + dropper_contract_id=args.dropper_contract_id, + claim_id=args.claim_id, + title=args.title, + description=args.description, + terminus_address=args.terminus_address, + terminus_pool_id=args.terminus_pool_id, + claim_block_deadline=args.block_deadline, + ) + except Exception as err: + logger.error(f"Unhandled /create_dropper_claim exception: {err}") + return + print(created_claim) + + +def dropper_activate_drop_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + activated_claim = actions.activate_claim( + db_session=db_session, + dropper_claim_id=args.dropper_claim_id, + ) + except Exception as err: + logger.error(f"Unhandled exception: {err}") + return + print(activated_claim) + + +def dropper_deactivate_drop_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + deactivated_claim = actions.deactivate_claim( + db_session=db_session, + dropper_claim_id=args.dropper_claim_id, + ) + except Exception as err: + logger.error(f"Unhandled exception: {err}") + return + print(deactivated_claim) + + +def dropper_admin_pool_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + ( + blockchain, + terminus_address, + terminus_pool_id, + ) = actions.get_claim_admin_pool( + db_session=db_session, dropper_claim_id=args.id + ) + except Exception as err: + logger.error(f"Unhandled exception: {err}") + return + + print( + f"Blockchain: {blockchain}, Terminus address: {terminus_address}, Pool ID: {terminus_pool_id}" + ) + + +def dropper_list_drops_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + dropper_claims = actions.list_claims( + db_session=db_session, + dropper_contract_id=args.dropper_contract_id, + active=args.active, + ) + except Exception as err: + logger.error(f"Unhandled /list_dropper_claims exception: {err}") + return + print(dropper_claims) + + +def dropper_delete_drop_handler(args: argparse.Namespace) -> None: + try: + with db.yield_db_session_ctx() as db_session: + removed_claim = actions.delete_claim( + db_session=db_session, + dropper_claim_id=args.dropper_claim_id, + ) + except Exception as err: + logger.error(f"Unhandled /delete_dropper_claim exception: {err}") + return + print(removed_claim) + + +def add_claimants_handler(args: argparse.Namespace) -> None: + """ + Load list of claimats from csv file and add them to the database. + """ + + claimants = [] + + with open(args.claimants_file, "r") as f: + reader = csv.DictReader(f) + + for row in reader: + if len(row) != 2: + logger.error(f"Invalid row: {row}") + raise Exception("Invalid row") + claimants.append({"address": row["address"], "amount": row["amount"]}) + + # format as DropAddClaimantsRequest + + claimants = data.DropAddClaimantsRequest( + dropper_claim_id=args.dropper_claim_id, claimants=claimants + ) + + with db.yield_db_session_ctx() as db_session: + try: + claimants = actions.add_claimants( + db_session=db_session, + dropper_claim_id=claimants.dropper_claim_id, + claimants=claimants.claimants, + added_by="cli", + ) + except Exception as err: + logger.error(f"Unhandled /add_claimants exception: {err}") + return + print(data.ClaimantsResponse(claimants=claimants).json()) + + +def delete_claimants_handler(args: argparse.Namespace) -> None: + """ + Read csv file and remove addresses in that list from claim + """ + + import csv + + addresses = [] + + with open(args.claimants_file, "r") as f: + reader = csv.DictReader(f) + + for row in reader: + if len(row) != 1: + logger.error(f"Invalid row: {row}") + raise Exception("Invalid row") + addresses.append(row["address"]) + + # format as DropRemoveClaimantsRequest + + removing_addresses = data.DropRemoveClaimantsRequest( + dropper_claim_id=args.dropper_claim_id, addresses=addresses + ) + + with db.yield_db_session_ctx() as db_session: + try: + addresses = actions.delete_claimants( + db_session=db_session, + dropper_claim_id=removing_addresses.dropper_claim_id, + addresses=removing_addresses.addresses, + ) + except Exception as err: + logger.error(f"Unhandled /delete_claimants exception: {err}") + return + print(data.RemoveClaimantsResponse(addresses=addresses).json()) + + +def list_claimants_handler(args: argparse.Namespace) -> None: + """ + List claimants for a claim + """ + + with db.yield_db_session_ctx() as db_session: + try: + claimants = actions.get_claimants( + db_session=db_session, dropper_claim_id=args.dropper_claim_id + ) + except Exception as err: + logger.error(f"Unhandled /list_claimants exception: {err}") + return + print(claimants) + + +def add_scores_handler(args: argparse.Namespace) -> None: + """ + Adding scores to leaderboard + """ + with open(args.input_file, "r") as f: + json_input = json.load(f) + + try: + new_scores = [data.Score(**score) for score in json_input] + except Exception as err: + logger.error(f"Can't parse json input in score format") + logger.error(f"Invalid input: {err}") + return + + with db.yield_db_session_ctx() as db_session: + try: + scores = actions.add_scores( + db_session=db_session, + leaderboard_id=args.leaderboard_id, + scores=new_scores, + overwrite=args.overwrite, + ) + except Exception as err: + logger.error(f"Unhandled /add_scores exception: {err}") + return + + +def list_leaderboards_handler(args: argparse.Namespace) -> None: + with db.yield_db_session_ctx() as db_session: + Leaderboards = actions.list_leaderboards( + db_session=db_session, + limit=args.limit, + offset=args.offset, + ) + + print(Leaderboards) + + +def create_leaderboard_handler(args: argparse.Namespace) -> None: + with db.yield_db_session_ctx() as db_session: + Leaderboard = actions.create_leaderboard( + db_session=db_session, + title=args.title, + description=args.description, + ) + + print(Leaderboard) + + +def assign_resource_handler(args: argparse.Namespace) -> None: + with db.yield_db_session_ctx() as db_session: + try: + resource_id = actions.assign_resource( + db_session=db_session, + resource_id=args.resource_id, + leaderboard_id=args.leaderboard_id, + ) + logger.info( + f"leaderboard:{args.leaderboard_id} assign resource_id:{resource_id}" + ) + except Exception as err: + logger.error(f"Unhandled /assign_resource exception: {err}") + return + + +def list_resources_handler(args: argparse.Namespace) -> None: + with db.yield_db_session_ctx() as db_session: + resources = actions.list_leaderboards_resources(db_session=db_session) + + logger.info(resources) + + +def revoke_resource_handler(args: argparse.Namespace) -> None: + with db.yield_db_session_ctx() as db_session: + try: + resource = actions.revoke_resource( + db_session=db_session, + leaderboard_id=args.leaderboard_id, + ) + logger.info( + f"leaderboard:{args.leaderboard_id} revoke resource current resource_id:{resource}" + ) + except Exception as err: + logger.error(f"Unhandled /revoke_resource exception: {err}") + return + + +def add_user_handler(args: argparse.Namespace) -> None: + """ + Add permission to resource cross bugout api. + """ + pass + + +def delete_user_handler(args: argparse.Namespace) -> None: + """ + Delete read access from resource cross bugout api. + """ + pass + + +def sign_handler(args: argparse.Namespace) -> None: + # Prompt user to enter the password for their signing account + password_raw = getpass.getpass( + prompt=f"Enter password for signing account ({args.signer}): " + ) + password = password_raw.strip() + signer = signatures.create_account_signer(args.signer, password) + signed_message = signer.sign_message(args.message) + print(signed_message) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="engineapi: The command line interface to Moonstream Engine API" + ) + parser.set_defaults(func=lambda _: parser.print_help()) + subparsers = parser.add_subparsers() + + parser_sign = subparsers.add_parser("sign", description="Manually sign a message") + parser_sign.add_argument( + "-m", "--message", required=True, type=str, help="Message to sign (hex bytes)" + ) + parser_sign.add_argument( + "-s", + "--signer", + required=True, + type=str, + help="Path to keystore file for signer", + ) + parser_sign.set_defaults(func=sign_handler) + + # Signing server parser + parser_signing_server = subparsers.add_parser( + "signing-server", description="Signing server commands" + ) + parser_signing_server.set_defaults( + func=lambda _: parser_signing_server.print_help() + ) + subparsers_signing_server = parser_signing_server.add_subparsers( + description="Signing server commands" + ) + + parser_signing_server_list = subparsers_signing_server.add_parser( + "list", description="List signing servers" + ) + parser_signing_server_list.add_argument( + "-i", + "--instance", + type=str, + help="Instance id to get", + ) + parser_signing_server_list.set_defaults(func=signing_server_list_handler) + + parser_signing_server_wakeup = subparsers_signing_server.add_parser( + "wakeup", description="Run signing server" + ) + parser_signing_server_wakeup.add_argument( + "-c", + "--confirmed", + action="store_true", + help="Provide confirmation flag to run signing instance", + ) + parser_signing_server_wakeup.add_argument( + "-d", + "--dry-run", + action="store_true", + help="Dry-run flag simulate instance start, using to check proper permissions", + ) + parser_signing_server_wakeup.set_defaults(func=signing_server_wakeup_handler) + + parser_signing_server_sleep = subparsers_signing_server.add_parser( + "sleep", description="Terminate signing server" + ) + parser_signing_server_sleep.add_argument( + "-i", + "--instance", + type=str, + required=True, + help="Instance id to terminate", + ) + parser_signing_server_sleep.add_argument( + "-c", + "--confirmed", + action="store_true", + help="Provide confirmation flag to terminate signing instance", + ) + parser_signing_server_sleep.add_argument( + "-d", + "--dry-run", + action="store_true", + help="Dry-run flag simulate instance termination, using to check proper permissions", + ) + parser_signing_server_sleep.set_defaults(func=signing_server_sleep_handler) + + # Auth parser + auth_parser = auth.generate_cli() + subparsers.add_parser("auth", parents=[auth_parser], add_help=False) + + # engine-database parser + parser_engine_database = subparsers.add_parser( + "engine-db", description="engine-db commands" + ) + parser_engine_database.set_defaults( + func=lambda _: parser_engine_database.print_help() + ) + subparsers_engine_database = parser_engine_database.add_subparsers( + description="Engine-db commands" + ) + + parser_leaderboard = subparsers_engine_database.add_parser( + "leaderboard", description="Leaderboard db commands" + ) + parser_leaderboard.set_defaults(func=lambda _: parser_leaderboard.print_help()) + + subparsers_leaderboard = parser_leaderboard.add_subparsers( + description="Leaderboard db commands" + ) + + parser_leaderboard_create = subparsers_leaderboard.add_parser( + "create-leaderboard", description="Create dropper contract" + ) + parser_leaderboard_create.add_argument( + "-t", + "--title", + type=str, + required=False, + help="Leaderboard title", + ) + parser_leaderboard_create.add_argument( + "-d", + "--description", + type=str, + required=False, + help="Leaderboard description", + ) + + parser_leaderboard_create.set_defaults(func=create_leaderboard_handler) + + parser_leaderboards_list = subparsers_leaderboard.add_parser( + "list-leaderboards", description="List leaderboards" + ) + parser_leaderboards_list.add_argument( + "--limit", + type=int, + default=10, + ) + parser_leaderboards_list.add_argument("--offset", type=int, default=0) + parser_leaderboards_list.set_defaults(func=list_leaderboards_handler) + + parser_leaderboard_score = subparsers_leaderboard.add_parser( + "add-scores", description="Add position to leaderboards score" + ) + + parser_leaderboard_score.add_argument( + "--leaderboard-id", + type=str, + required=True, + help="Contract description", + ) + parser_leaderboard_score.add_argument( + "--input-file", + type=str, + required=True, + help="File with scores", + ) + + parser_leaderboard_score.add_argument("--overwrite", type=bool, default=True) + + parser_leaderboard_score.set_defaults(func=add_scores_handler) + + parser_leaderboard_permissions = subparsers_leaderboard.add_parser( + "permissions", description="Manage leaderboard permissions" + ) + + parser_leaderboard_permissions.set_defaults( + func=lambda _: parser_leaderboard_score.print_help() + ) + + subparsers_leaderboard_permissions = parser_leaderboard_permissions.add_subparsers( + description="Manage leaderboard permissions" + ) + + parser_leaderboard_resource_assign = subparsers_leaderboard_permissions.add_parser( + "assign", description="Assign resource to leaderboard" + ) + + parser_leaderboard_resource_assign.add_argument( + "--leaderboard-id", + type=str, + required=True, + help="Leaderboard id", + ) + + parser_leaderboard_resource_assign.add_argument( + "--resource-id", + type=UUID, + required=False, + help="Resource id", + ) + + parser_leaderboard_resource_assign.set_defaults(func=assign_resource_handler) + + parser_leaderboard_resource_revoke = subparsers_leaderboard_permissions.add_parser( + "revoke", description="Revoke resource from leaderboard" + ) + + parser_leaderboard_resource_revoke.add_argument( + "--leaderboard-id", + type=str, + required=True, + help="Leaderboard id", + ) + + parser_leaderboard_resource_revoke.set_defaults(func=revoke_resource_handler) + + parser_leaderboard_resource_list = subparsers_leaderboard_permissions.add_parser( + "list", description="List leaderboard resources and ids" + ) + + parser_leaderboard_resource_list.set_defaults(func=list_resources_handler) + + parser_leaderboard_resource_add_user = ( + subparsers_leaderboard_permissions.add_parser( + "add-user", description="Add to user write access to leaderboard" + ) + ) + parser_leaderboard_resource_add_user.add_argument( + "--leaderboard-id", + type=str, + required=True, + help="Leaderboard id", + ) + + parser_leaderboard_resource_add_user.add_argument( + "--user-id", + type=str, + required=True, + help="User id", + ) + + parser_leaderboard_resource_add_user.set_defaults(func=add_user_handler) + + parser_leaderboard_resource_remove_user = ( + subparsers_leaderboard_permissions.add_parser( + "remove-user", description="Delete write access to leaderboard from user" + ) + ) + + parser_leaderboard_resource_remove_user.add_argument( + "--leaderboard-id", + type=str, + required=True, + help="Leaderboard id", + ) + + parser_leaderboard_resource_remove_user.add_argument( + "--user-id", + type=str, + required=True, + help="User id", + ) + + parser_leaderboard_resource_remove_user.set_defaults(func=delete_user_handler) + + parser_dropper = subparsers_engine_database.add_parser( + "dropper", description="Dropper db commands" + ) + parser_dropper.set_defaults(func=lambda _: parser_dropper.print_help()) + + subparsers_dropper = parser_dropper.add_subparsers( + description="Dropper db commands" + ) + + parser_dropper_contract_create = subparsers_dropper.add_parser( + "create-contract", description="Create dropper contract" + ) + parser_dropper_contract_create.add_argument( + "-b", + "--blockchain", + type=str, + required=True, + help="Blockchain in wich contract was deployed", + ) + parser_dropper_contract_create.add_argument( + "-a", + "--address", + type=str, + required=True, + help="Contract address", + ) + parser_dropper_contract_create.add_argument( + "-t", + "--title", + type=str, + required=False, + help="Contract title", + ) + parser_dropper_contract_create.add_argument( + "-d", + "--description", + type=str, + required=False, + help="Contract description", + ) + parser_dropper_contract_create.add_argument( + "-i", + "--image-uri", + type=str, + required=False, + help="Contract image uri", + ) + + parser_dropper_contract_create.set_defaults(func=create_dropper_contract_handler) + + parser_dropper_contract_list = subparsers_dropper.add_parser( + "list-contracts", description="List dropper contracts" + ) + parser_dropper_contract_list.add_argument( + "-b", + "--blockchain", + type=str, + required=True, + help="Blockchain in wich contract was deployed", + ) + parser_dropper_contract_list.set_defaults(func=list_dropper_contracts_handler) + + parser_dropper_contract_delete = subparsers_dropper.add_parser( + "delete-contract", description="Delete dropper contract" + ) + parser_dropper_contract_delete.add_argument( + "-b", + "--blockchain", + type=str, + required=True, + help="Blockchain in wich contract was deployed", + ) + parser_dropper_contract_delete.add_argument( + "-a", + "--address", + type=str, + required=True, + help="Contract address", + ) + parser_dropper_contract_delete.set_defaults(func=delete_dropper_contract_handler) + + parser_dropper_create_drop = subparsers_dropper.add_parser( + "create-drop", description="Create dropper drop" + ) + parser_dropper_create_drop.add_argument( + "-c", + "--dropper-contract-id", + type=str, + required=True, + help="Dropper contract id", + ) + parser_dropper_create_drop.add_argument( + "-t", + "--title", + type=str, + required=True, + help="Drop title", + ) + parser_dropper_create_drop.add_argument( + "-d", + "--description", + type=str, + required=True, + help="Drop description", + ) + parser_dropper_create_drop.add_argument( + "-b", + "--block-deadline", + type=int, + required=True, + help="Block deadline at which signature will be not returned", + ) + parser_dropper_create_drop.add_argument( + "-T", + "--terminus-address", + type=str, + required=True, + help="Terminus address", + ) + parser_dropper_create_drop.add_argument( + "-p", + "--terminus-pool-id", + type=int, + required=True, + help="Terminus pool id", + ) + parser_dropper_create_drop.add_argument( + "-m", + "--claim-id", + type=int, + help="Claim id", + ) + + parser_dropper_create_drop.set_defaults(func=dropper_create_drop_handler) + + parser_dropper_activate_drop = subparsers_dropper.add_parser( + "activate-drop", description="Activate dropper drop" + ) + parser_dropper_activate_drop.add_argument( + "-c", + "--dropper-claim-id", + type=str, + required=True, + help="Dropper claim id", + ) + parser_dropper_activate_drop.set_defaults(func=dropper_activate_drop_handler) + + parser_dropper_deactivate_drop = subparsers_dropper.add_parser( + "deactivate-drop", description="Deactivate dropper drop" + ) + parser_dropper_deactivate_drop.add_argument( + "-c", + "--dropper-claim-id", + type=str, + required=True, + help="Dropper claim id", + ) + parser_dropper_deactivate_drop.set_defaults(func=dropper_deactivate_drop_handler) + + parser_dropper_get_claim_admin_pool = subparsers_dropper.add_parser( + "admin-pool", description="Get admin pool for drop" + ) + parser_dropper_get_claim_admin_pool.add_argument( + "-i", "--id", required=True, help="Dropper Claim ID (Database ID)" + ) + parser_dropper_get_claim_admin_pool.set_defaults(func=dropper_admin_pool_handler) + + parser_dropper_list_drops = subparsers_dropper.add_parser( + "list-drops", description="List dropper drops" + ) + parser_dropper_list_drops.add_argument( + "-a", + "--active", + type=bool, + required=True, + help="Claim is active flag", + ) + parser_dropper_list_drops.add_argument( + "-c", + "--dropper-contract-id", + type=str, + required=True, + help="Dropper contract id", + ) + parser_dropper_list_drops.set_defaults(func=dropper_list_drops_handler) + + parser_dropper_delete_drop = subparsers_dropper.add_parser( + "delete-drop", description="Delete dropper drop" + ) + parser_dropper_delete_drop.add_argument( + "-d", + "--dropper-claim-id", + type=str, + required=True, + help="Drop id in database", + ) + parser_dropper_delete_drop.set_defaults(func=dropper_delete_drop_handler) + + parser_dropper_add_claimants = subparsers_dropper.add_parser( + "add-claimants", description="Add claimants to drop" + ) + parser_dropper_add_claimants.add_argument( + "-c", + "--dropper-claim-id", + type=str, + required=True, + help="Id of particular claim", + ) + parser_dropper_add_claimants.add_argument( + "-f", + "--claimants-file", + type=str, + required=True, + help="Csv of claimants addresses", + ) + parser_dropper_add_claimants.set_defaults(func=add_claimants_handler) + + parser_dropper_delete_claimants = subparsers_dropper.add_parser( + "delete-claimants", description="Delete claimants from drop" + ) + parser_dropper_delete_claimants.add_argument( + "-c", + "--dropper-claim-id", + type=str, + required=True, + help="Id of particular claim", + ) + parser_dropper_delete_claimants.add_argument( + "-f", + "--claimants-file", + type=str, + required=True, + help="Csv of claimants addresses", + ) + parser_dropper_delete_claimants.set_defaults(func=delete_claimants_handler) + + parser_dropper_list_claimants = subparsers_dropper.add_parser( + "list-claimants", description="List claimants of drop" + ) + parser_dropper_list_claimants.add_argument( + "-c", "--dropper-claim-id", type=str, required=True, help="Dropper claim id" + ) + parser_dropper_list_claimants.set_defaults(func=list_claimants_handler) + + contracts_parser = contracts_actions.generate_cli() + subparsers_engine_database.add_parser( + "contracts", parents=[contracts_parser], add_help=False + ) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/engineapi/engineapi/contracts/Dropper_abi.json b/engineapi/engineapi/contracts/Dropper_abi.json new file mode 100644 index 00000000..d968f34a --- /dev/null +++ b/engineapi/engineapi/contracts/Dropper_abi.json @@ -0,0 +1 @@ +[{"inputs": [], "stateMutability": "nonpayable", "type": "constructor"}, {"anonymous": false, "inputs": [{"indexed": false, "internalType": "uint256", "name": "claimId", "type": "uint256"}, {"indexed": true, "internalType": "uint256", "name": "tokenType", "type": "uint256"}, {"indexed": true, "internalType": "address", "name": "tokenAddress", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "ClaimCreated", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "claimId", "type": "uint256"}, {"indexed": false, "internalType": "address", "name": "signer", "type": "address"}], "name": "ClaimSignerChanged", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "claimId", "type": "uint256"}, {"indexed": false, "internalType": "bool", "name": "status", "type": "bool"}], "name": "ClaimStatusChanged", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "claimId", "type": "uint256"}, {"indexed": true, "internalType": "address", "name": "claimant", "type": "address"}], "name": "Claimed", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "previousOwner", "type": "address"}, {"indexed": true, "internalType": "address", "name": "newOwner", "type": "address"}], "name": "OwnershipTransferred", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": false, "internalType": "address", "name": "account", "type": "address"}], "name": "Paused", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": false, "internalType": "address", "name": "account", "type": "address"}], "name": "Unpaused", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": false, "internalType": "address", "name": "recipient", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenType", "type": "uint256"}, {"indexed": true, "internalType": "address", "name": "tokenAddress", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "Withdrawal", "type": "event"}, {"inputs": [], "name": "ERC1155_TYPE", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "ERC20_TYPE", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "ERC721_TYPE", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "TERMINUS_MINTABLE_TYPE", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}, {"internalType": "uint256", "name": "blockDeadline", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}, {"internalType": "bytes", "name": "signature", "type": "bytes"}], "name": "claim", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}, {"internalType": "address", "name": "claimant", "type": "address"}, {"internalType": "uint256", "name": "blockDeadline", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "claimMessageHash", "outputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}], "name": "claimStatus", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}], "name": "claimUri", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "tokenType", "type": "uint256"}, {"internalType": "address", "name": "tokenAddress", "type": "address"}, {"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "createClaim", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}], "name": "getClaim", "outputs": [{"components": [{"internalType": "uint256", "name": "tokenType", "type": "uint256"}, {"internalType": "address", "name": "tokenAddress", "type": "address"}, {"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "internalType": "struct Dropper.ClaimableToken", "name": "", "type": "tuple"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}, {"internalType": "address", "name": "claimant", "type": "address"}], "name": "getClaimStatus", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}], "name": "getSignerForClaim", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "numClaims", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "", "type": "address"}, {"internalType": "address", "name": "", "type": "address"}, {"internalType": "uint256[]", "name": "", "type": "uint256[]"}, {"internalType": "uint256[]", "name": "", "type": "uint256[]"}, {"internalType": "bytes", "name": "", "type": "bytes"}], "name": "onERC1155BatchReceived", "outputs": [{"internalType": "bytes4", "name": "", "type": "bytes4"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "", "type": "address"}, {"internalType": "address", "name": "", "type": "address"}, {"internalType": "uint256", "name": "", "type": "uint256"}, {"internalType": "uint256", "name": "", "type": "uint256"}, {"internalType": "bytes", "name": "", "type": "bytes"}], "name": "onERC1155Received", "outputs": [{"internalType": "bytes4", "name": "", "type": "bytes4"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "operator", "type": "address"}, {"internalType": "address", "name": "from", "type": "address"}, {"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "bytes", "name": "data", "type": "bytes"}], "name": "onERC721Received", "outputs": [{"internalType": "bytes4", "name": "", "type": "bytes4"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [], "name": "owner", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "paused", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}, {"internalType": "bool", "name": "status", "type": "bool"}], "name": "setClaimStatus", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}, {"internalType": "string", "name": "uri", "type": "string"}], "name": "setClaimUri", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "claimId", "type": "uint256"}, {"internalType": "address", "name": "signer", "type": "address"}], "name": "setSignerForClaim", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "bytes4", "name": "interfaceId", "type": "bytes4"}], "name": "supportsInterface", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolId", "type": "uint256"}, {"internalType": "address", "name": "terminusAddress", "type": "address"}, {"internalType": "address", "name": "newPoolController", "type": "address"}], "name": "surrenderPoolControl", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "newOwner", "type": "address"}], "name": "transferOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "tokenAddress", "type": "address"}, {"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "withdrawERC1155", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "tokenAddress", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "withdrawERC20", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "tokenAddress", "type": "address"}, {"internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "withdrawERC721", "outputs": [], "stateMutability": "nonpayable", "type": "function"}] \ No newline at end of file diff --git a/engineapi/engineapi/contracts/Dropper_interface.py b/engineapi/engineapi/contracts/Dropper_interface.py new file mode 100644 index 00000000..6551c0d9 --- /dev/null +++ b/engineapi/engineapi/contracts/Dropper_interface.py @@ -0,0 +1,177 @@ +# Code generated by moonworm : https://github.com/bugout-dev/moonworm +# Moonworm version : 0.2.4 +import json +import os +from typing import Any, Dict, Union + +from eth_typing.evm import Address, ChecksumAddress +from web3 import Web3 +from web3.contract import ContractFunction + +from .web3_util import * + +abi_path = os.path.join(os.path.dirname(__file__), "Dropper_abi.json") +with open(abi_path, "r") as abi_file: + CONTRACT_ABI = json.load(abi_file) + + +class Contract: + def __init__(self, web3: Web3, contract_address: ChecksumAddress): + self.web3 = web3 + self.address = contract_address + self.contract = web3.eth.contract(address=self.address, abi=CONTRACT_ABI) + + @staticmethod + def constructor() -> ContractConstructor: + return ContractConstructor() + + def ERC1155_TYPE(self) -> ContractFunction: + return self.contract.functions.ERC1155_TYPE() + + def ERC20_TYPE(self) -> ContractFunction: + return self.contract.functions.ERC20_TYPE() + + def ERC721_TYPE(self) -> ContractFunction: + return self.contract.functions.ERC721_TYPE() + + def TERMINUS_MINTABLE_TYPE(self) -> ContractFunction: + return self.contract.functions.TERMINUS_MINTABLE_TYPE() + + def claim( + self, claimId: int, blockDeadline: int, amount: int, signature: bytes + ) -> ContractFunction: + return self.contract.functions.claim(claimId, blockDeadline, amount, signature) + + def claimMessageHash( + self, claimId: int, claimant: ChecksumAddress, blockDeadline: int, amount: int + ) -> ContractFunction: + return self.contract.functions.claimMessageHash( + claimId, claimant, blockDeadline, amount + ) + + def claimStatus(self, claimId: int) -> ContractFunction: + return self.contract.functions.claimStatus(claimId) + + def claimUri(self, claimId: int) -> ContractFunction: + return self.contract.functions.claimUri(claimId) + + def createClaim( + self, tokenType: int, tokenAddress: ChecksumAddress, tokenId: int, amount: int + ) -> ContractFunction: + return self.contract.functions.createClaim( + tokenType, tokenAddress, tokenId, amount + ) + + def getClaim(self, claimId: int) -> ContractFunction: + return self.contract.functions.getClaim(claimId) + + def getClaimStatus( + self, claimId: int, claimant: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.getClaimStatus(claimId, claimant) + + def getSignerForClaim(self, claimId: int) -> ContractFunction: + return self.contract.functions.getSignerForClaim(claimId) + + def numClaims(self) -> ContractFunction: + return self.contract.functions.numClaims() + + def onERC1155BatchReceived( + self, + arg1: ChecksumAddress, + arg2: ChecksumAddress, + arg3: List, + arg4: List, + arg5: bytes, + ) -> ContractFunction: + return self.contract.functions.onERC1155BatchReceived( + arg1, arg2, arg3, arg4, arg5 + ) + + def onERC1155Received( + self, + arg1: ChecksumAddress, + arg2: ChecksumAddress, + arg3: int, + arg4: int, + arg5: bytes, + ) -> ContractFunction: + return self.contract.functions.onERC1155Received(arg1, arg2, arg3, arg4, arg5) + + def onERC721Received( + self, + operator: ChecksumAddress, + from_: ChecksumAddress, + tokenId: int, + data: bytes, + ) -> ContractFunction: + return self.contract.functions.onERC721Received(operator, from_, tokenId, data) + + def owner(self) -> ContractFunction: + return self.contract.functions.owner() + + def paused(self) -> ContractFunction: + return self.contract.functions.paused() + + def renounceOwnership(self) -> ContractFunction: + return self.contract.functions.renounceOwnership() + + def setClaimStatus(self, claimId: int, status: bool) -> ContractFunction: + return self.contract.functions.setClaimStatus(claimId, status) + + def setClaimUri(self, claimId: int, uri: str) -> ContractFunction: + return self.contract.functions.setClaimUri(claimId, uri) + + def setSignerForClaim( + self, claimId: int, signer: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.setSignerForClaim(claimId, signer) + + def supportsInterface(self, interfaceId: bytes) -> ContractFunction: + return self.contract.functions.supportsInterface(interfaceId) + + def surrenderPoolControl( + self, + poolId: int, + terminusAddress: ChecksumAddress, + newPoolController: ChecksumAddress, + ) -> ContractFunction: + return self.contract.functions.surrenderPoolControl( + poolId, terminusAddress, newPoolController + ) + + def transferOwnership(self, newOwner: ChecksumAddress) -> ContractFunction: + return self.contract.functions.transferOwnership(newOwner) + + def withdrawERC1155( + self, tokenAddress: ChecksumAddress, tokenId: int, amount: int + ) -> ContractFunction: + return self.contract.functions.withdrawERC1155(tokenAddress, tokenId, amount) + + def withdrawERC20( + self, tokenAddress: ChecksumAddress, amount: int + ) -> ContractFunction: + return self.contract.functions.withdrawERC20(tokenAddress, amount) + + def withdrawERC721( + self, tokenAddress: ChecksumAddress, tokenId: int + ) -> ContractFunction: + return self.contract.functions.withdrawERC721(tokenAddress, tokenId) + + +def deploy( + web3: Web3, + contract_constructor: ContractFunction, + contract_bytecode: str, + deployer_address: ChecksumAddress, + deployer_private_key: str, +) -> Contract: + tx_hash, contract_address = deploy_contract_from_constructor_function( + web3, + constructor=contract_constructor, + contract_bytecode=contract_bytecode, + contract_abi=CONTRACT_ABI, + deployer=deployer_address, + deployer_private_key=deployer_private_key, + ) + return Contract(web3, contract_address) diff --git a/engineapi/engineapi/contracts/ERC20_abi.json b/engineapi/engineapi/contracts/ERC20_abi.json new file mode 100644 index 00000000..c2b8f042 --- /dev/null +++ b/engineapi/engineapi/contracts/ERC20_abi.json @@ -0,0 +1 @@ +[{"inputs": [{"internalType": "string", "name": "name", "type": "string"}, {"internalType": "string", "name": "symbol", "type": "string"}], "stateMutability": "nonpayable", "type": "constructor"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "owner", "type": "address"}, {"indexed": true, "internalType": "address", "name": "spender", "type": "address"}, {"indexed": false, "internalType": "uint256", "name": "value", "type": "uint256"}], "name": "Approval", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": false, "internalType": "uint256", "name": "value", "type": "uint256"}], "name": "Transfer", "type": "event"}, {"inputs": [{"internalType": "address", "name": "owner", "type": "address"}, {"internalType": "address", "name": "spender", "type": "address"}], "name": "allowance", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "approve", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "subtractedValue", "type": "uint256"}], "name": "decreaseAllowance", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "addedValue", "type": "uint256"}], "name": "increaseAllowance", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "account", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "mint", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [], "name": "name", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "symbol", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "totalSupply", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "transfer", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "sender", "type": "address"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "transferFrom", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}] \ No newline at end of file diff --git a/engineapi/engineapi/contracts/ERC20_interface.py b/engineapi/engineapi/contracts/ERC20_interface.py new file mode 100644 index 00000000..73816c9a --- /dev/null +++ b/engineapi/engineapi/contracts/ERC20_interface.py @@ -0,0 +1,88 @@ +# Code generated by moonworm : https://github.com/bugout-dev/moonworm +# Moonworm version : 0.2.4 +import json +import os +from typing import Any, Dict, Union + +from eth_typing.evm import Address, ChecksumAddress +from web3 import Web3 +from web3.contract import ContractFunction + +from .web3_util import * + +abi_path = os.path.join(os.path.dirname(__file__), "ERC20_abi.json") +with open(abi_path, "r") as abi_file: + CONTRACT_ABI = json.load(abi_file) + + +class Contract: + def __init__(self, web3: Web3, contract_address: ChecksumAddress): + self.web3 = web3 + self.address = contract_address + self.contract = web3.eth.contract(address=self.address, abi=CONTRACT_ABI) + + @staticmethod + def constructor(name: str, symbol: str) -> ContractConstructor: + return ContractConstructor(name, symbol) + + def allowance( + self, owner: ChecksumAddress, spender: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.allowance(owner, spender) + + def approve(self, spender: ChecksumAddress, amount: int) -> ContractFunction: + return self.contract.functions.approve(spender, amount) + + def balanceOf(self, account: ChecksumAddress) -> ContractFunction: + return self.contract.functions.balanceOf(account) + + def decimals(self) -> ContractFunction: + return self.contract.functions.decimals() + + def decreaseAllowance( + self, spender: ChecksumAddress, subtractedValue: int + ) -> ContractFunction: + return self.contract.functions.decreaseAllowance(spender, subtractedValue) + + def increaseAllowance( + self, spender: ChecksumAddress, addedValue: int + ) -> ContractFunction: + return self.contract.functions.increaseAllowance(spender, addedValue) + + def mint(self, account: ChecksumAddress, amount: int) -> ContractFunction: + return self.contract.functions.mint(account, amount) + + def name(self) -> ContractFunction: + return self.contract.functions.name() + + def symbol(self) -> ContractFunction: + return self.contract.functions.symbol() + + def totalSupply(self) -> ContractFunction: + return self.contract.functions.totalSupply() + + def transfer(self, recipient: ChecksumAddress, amount: int) -> ContractFunction: + return self.contract.functions.transfer(recipient, amount) + + def transferFrom( + self, sender: ChecksumAddress, recipient: ChecksumAddress, amount: int + ) -> ContractFunction: + return self.contract.functions.transferFrom(sender, recipient, amount) + + +def deploy( + web3: Web3, + contract_constructor: ContractFunction, + contract_bytecode: str, + deployer_address: ChecksumAddress, + deployer_private_key: str, +) -> Contract: + tx_hash, contract_address = deploy_contract_from_constructor_function( + web3, + constructor=contract_constructor, + contract_bytecode=contract_bytecode, + contract_abi=CONTRACT_ABI, + deployer=deployer_address, + deployer_private_key=deployer_private_key, + ) + return Contract(web3, contract_address) diff --git a/engineapi/engineapi/contracts/Terminus_abi.json b/engineapi/engineapi/contracts/Terminus_abi.json new file mode 100644 index 00000000..a647e830 --- /dev/null +++ b/engineapi/engineapi/contracts/Terminus_abi.json @@ -0,0 +1 @@ +[{"inputs": [], "stateMutability": "nonpayable", "type": "constructor"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "account", "type": "address"}, {"indexed": true, "internalType": "address", "name": "operator", "type": "address"}, {"indexed": false, "internalType": "bool", "name": "approved", "type": "bool"}], "name": "ApprovalForAll", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "id", "type": "uint256"}, {"indexed": true, "internalType": "address", "name": "operator", "type": "address"}, {"indexed": false, "internalType": "address", "name": "from", "type": "address"}, {"indexed": false, "internalType": "address[]", "name": "toAddresses", "type": "address[]"}, {"indexed": false, "internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}], "name": "PoolMintBatch", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "operator", "type": "address"}, {"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": false, "internalType": "uint256[]", "name": "ids", "type": "uint256[]"}, {"indexed": false, "internalType": "uint256[]", "name": "values", "type": "uint256[]"}], "name": "TransferBatch", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "operator", "type": "address"}, {"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": false, "internalType": "uint256", "name": "id", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "value", "type": "uint256"}], "name": "TransferSingle", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": false, "internalType": "string", "name": "value", "type": "string"}, {"indexed": true, "internalType": "uint256", "name": "id", "type": "uint256"}], "name": "URI", "type": "event"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}, {"internalType": "address", "name": "operator", "type": "address"}], "name": "approveForPool", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "account", "type": "address"}, {"internalType": "uint256", "name": "id", "type": "uint256"}], "name": "balanceOf", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address[]", "name": "accounts", "type": "address[]"}, {"internalType": "uint256[]", "name": "ids", "type": "uint256[]"}], "name": "balanceOfBatch", "outputs": [{"internalType": "uint256[]", "name": "", "type": "uint256[]"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "from", "type": "address"}, {"internalType": "uint256", "name": "poolID", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "burn", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [], "name": "contractURI", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "_capacity", "type": "uint256"}, {"internalType": "bool", "name": "_transferable", "type": "bool"}, {"internalType": "bool", "name": "_burnable", "type": "bool"}], "name": "createPoolV1", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "_capacity", "type": "uint256"}], "name": "createSimplePool", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "account", "type": "address"}, {"internalType": "address", "name": "operator", "type": "address"}], "name": "isApprovedForAll", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}, {"internalType": "address", "name": "operator", "type": "address"}], "name": "isApprovedForPool", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "to", "type": "address"}, {"internalType": "uint256", "name": "poolID", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}, {"internalType": "bytes", "name": "data", "type": "bytes"}], "name": "mint", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "to", "type": "address"}, {"internalType": "uint256[]", "name": "poolIDs", "type": "uint256[]"}, {"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}, {"internalType": "bytes", "name": "data", "type": "bytes"}], "name": "mintBatch", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [], "name": "paymentToken", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "poolBasePrice", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "id", "type": "uint256"}, {"internalType": "address[]", "name": "toAddresses", "type": "address[]"}, {"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}], "name": "poolMintBatch", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "from", "type": "address"}, {"internalType": "address", "name": "to", "type": "address"}, {"internalType": "uint256[]", "name": "ids", "type": "uint256[]"}, {"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}, {"internalType": "bytes", "name": "data", "type": "bytes"}], "name": "safeBatchTransferFrom", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "from", "type": "address"}, {"internalType": "address", "name": "to", "type": "address"}, {"internalType": "uint256", "name": "id", "type": "uint256"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}, {"internalType": "bytes", "name": "data", "type": "bytes"}], "name": "safeTransferFrom", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "operator", "type": "address"}, {"internalType": "bool", "name": "approved", "type": "bool"}], "name": "setApprovalForAll", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "string", "name": "_contractURI", "type": "string"}], "name": "setContractURI", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "newController", "type": "address"}], "name": "setController", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "newPaymentToken", "type": "address"}], "name": "setPaymentToken", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "newBasePrice", "type": "uint256"}], "name": "setPoolBasePrice", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}, {"internalType": "address", "name": "newController", "type": "address"}], "name": "setPoolController", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}, {"internalType": "string", "name": "poolURI", "type": "string"}], "name": "setURI", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "bytes4", "name": "interfaceId", "type": "bytes4"}], "name": "supportsInterface", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "terminusController", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}], "name": "terminusPoolCapacity", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}], "name": "terminusPoolController", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}], "name": "terminusPoolSupply", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "totalPools", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "poolID", "type": "uint256"}], "name": "uri", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "toAddress", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "withdrawPayments", "outputs": [], "stateMutability": "nonpayable", "type": "function"}] \ No newline at end of file diff --git a/engineapi/engineapi/contracts/Terminus_interface.py b/engineapi/engineapi/contracts/Terminus_interface.py new file mode 100644 index 00000000..19901284 --- /dev/null +++ b/engineapi/engineapi/contracts/Terminus_interface.py @@ -0,0 +1,175 @@ +# Code generated by moonworm : https://github.com/bugout-dev/moonworm +# Moonworm version : 0.2.4 +import json +import os +from typing import Any, Dict, Union + +from eth_typing.evm import Address, ChecksumAddress +from web3 import Web3 +from web3.contract import ContractFunction + +from .web3_util import * + +abi_path = os.path.join(os.path.dirname(__file__), "Terminus_abi.json") +with open(abi_path, "r") as abi_file: + CONTRACT_ABI = json.load(abi_file) + + +class Contract: + def __init__(self, web3: Web3, contract_address: ChecksumAddress): + self.web3 = web3 + self.address = contract_address + self.contract = web3.eth.contract(address=self.address, abi=CONTRACT_ABI) + + @staticmethod + def constructor() -> ContractConstructor: + return ContractConstructor() + + def approveForPool( + self, poolID: int, operator: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.approveForPool(poolID, operator) + + def balanceOf(self, account: ChecksumAddress, id: int) -> ContractFunction: + return self.contract.functions.balanceOf(account, id) + + def balanceOfBatch(self, accounts: List, ids: List) -> ContractFunction: + return self.contract.functions.balanceOfBatch(accounts, ids) + + def burn( + self, from_: ChecksumAddress, poolID: int, amount: int + ) -> ContractFunction: + return self.contract.functions.burn(from_, poolID, amount) + + def contractURI(self) -> ContractFunction: + return self.contract.functions.contractURI() + + def createPoolV1( + self, _capacity: int, _transferable: bool, _burnable: bool + ) -> ContractFunction: + return self.contract.functions.createPoolV1(_capacity, _transferable, _burnable) + + def createSimplePool(self, _capacity: int) -> ContractFunction: + return self.contract.functions.createSimplePool(_capacity) + + def isApprovedForAll( + self, account: ChecksumAddress, operator: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.isApprovedForAll(account, operator) + + def isApprovedForPool( + self, poolID: int, operator: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.isApprovedForPool(poolID, operator) + + def mint( + self, to: ChecksumAddress, poolID: int, amount: int, data: bytes + ) -> ContractFunction: + return self.contract.functions.mint(to, poolID, amount, data) + + def mintBatch( + self, to: ChecksumAddress, poolIDs: List, amounts: List, data: bytes + ) -> ContractFunction: + return self.contract.functions.mintBatch(to, poolIDs, amounts, data) + + def paymentToken(self) -> ContractFunction: + return self.contract.functions.paymentToken() + + def poolBasePrice(self) -> ContractFunction: + return self.contract.functions.poolBasePrice() + + def poolMintBatch( + self, id: int, toAddresses: List, amounts: List + ) -> ContractFunction: + return self.contract.functions.poolMintBatch(id, toAddresses, amounts) + + def safeBatchTransferFrom( + self, + from_: ChecksumAddress, + to: ChecksumAddress, + ids: List, + amounts: List, + data: bytes, + ) -> ContractFunction: + return self.contract.functions.safeBatchTransferFrom( + from_, to, ids, amounts, data + ) + + def safeTransferFrom( + self, + from_: ChecksumAddress, + to: ChecksumAddress, + id: int, + amount: int, + data: bytes, + ) -> ContractFunction: + return self.contract.functions.safeTransferFrom(from_, to, id, amount, data) + + def setApprovalForAll( + self, operator: ChecksumAddress, approved: bool + ) -> ContractFunction: + return self.contract.functions.setApprovalForAll(operator, approved) + + def setContractURI(self, _contractURI: str) -> ContractFunction: + return self.contract.functions.setContractURI(_contractURI) + + def setController(self, newController: ChecksumAddress) -> ContractFunction: + return self.contract.functions.setController(newController) + + def setPaymentToken(self, newPaymentToken: ChecksumAddress) -> ContractFunction: + return self.contract.functions.setPaymentToken(newPaymentToken) + + def setPoolBasePrice(self, newBasePrice: int) -> ContractFunction: + return self.contract.functions.setPoolBasePrice(newBasePrice) + + def setPoolController( + self, poolID: int, newController: ChecksumAddress + ) -> ContractFunction: + return self.contract.functions.setPoolController(poolID, newController) + + def setURI(self, poolID: int, poolURI: str) -> ContractFunction: + return self.contract.functions.setURI(poolID, poolURI) + + def supportsInterface(self, interfaceId: bytes) -> ContractFunction: + return self.contract.functions.supportsInterface(interfaceId) + + def terminusController(self) -> ContractFunction: + return self.contract.functions.terminusController() + + def terminusPoolCapacity(self, poolID: int) -> ContractFunction: + return self.contract.functions.terminusPoolCapacity(poolID) + + def terminusPoolController(self, poolID: int) -> ContractFunction: + return self.contract.functions.terminusPoolController(poolID) + + def terminusPoolSupply(self, poolID: int) -> ContractFunction: + return self.contract.functions.terminusPoolSupply(poolID) + + def totalPools(self) -> ContractFunction: + return self.contract.functions.totalPools() + + def uri(self, poolID: int) -> ContractFunction: + return self.contract.functions.uri(poolID) + + def withdrawPayments( + self, toAddress: ChecksumAddress, amount: int + ) -> ContractFunction: + return self.contract.functions.withdrawPayments(toAddress, amount) + + +def deploy( + web3: Web3, + contract_constructor: ContractFunction, + contract_bytecode: str, + deployer_address: ChecksumAddress, + deployer_private_key: str, +) -> Contract: + tx_hash, contract_address = deploy_contract_from_constructor_function( + web3, + constructor=contract_constructor, + contract_bytecode=contract_bytecode, + contract_abi=CONTRACT_ABI, + deployer=deployer_address, + deployer_private_key=deployer_private_key, + ) + return Contract(web3, contract_address) diff --git a/engineapi/engineapi/contracts/__init__.py b/engineapi/engineapi/contracts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engineapi/engineapi/contracts/web3_util.py b/engineapi/engineapi/contracts/web3_util.py new file mode 100644 index 00000000..34949230 --- /dev/null +++ b/engineapi/engineapi/contracts/web3_util.py @@ -0,0 +1,201 @@ +import getpass +import os +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import web3 +from eth_account.account import Account # type: ignore +from eth_typing.evm import ChecksumAddress +from hexbytes.main import HexBytes +from web3 import Web3 +from web3.contract import Contract, ContractFunction +from web3.providers.ipc import IPCProvider +from web3.providers.rpc import HTTPProvider +from web3.types import ABI, Nonce, TxParams, TxReceipt, Wei + + +class ContractConstructor: + def __init__(self, *args: Any): + self.args = args + + +def build_transaction( + web3: Web3, + builder: Union[ContractFunction, Any], + sender: ChecksumAddress, +) -> Union[TxParams, Any]: + """ + Builds transaction json with the given arguments. It is not submitting transaction + Arguments: + - web3: Web3 client + - builder: ContractFunction or other class that has method buildTransaction(TxParams) + - sender: `from` value of transaction, address which is sending this transaction + - maxFeePerGas: Optional, max priority fee for dynamic fee transactions in Wei + - maxPriorityFeePerGas: Optional the part of the fee that goes to the miner + """ + + transaction = builder.buildTransaction( + { + "from": sender, + "nonce": get_nonce(web3, sender), + } + ) + return transaction + + +def get_nonce(web3: Web3, address: ChecksumAddress) -> Nonce: + """ + Returns Nonce: number of transactions for given address + """ + nonce = web3.eth.get_transaction_count(address) + return nonce + + +def submit_transaction( + web3: Web3, transaction: Union[TxParams, Any], signer_private_key: str +) -> HexBytes: + + """ + Signs and submits json transaction to blockchain from the name of signer + """ + signed_transaction = web3.eth.account.sign_transaction( + transaction, private_key=signer_private_key + ) + return submit_signed_raw_transaction(web3, signed_transaction.rawTransaction) + + +def submit_signed_raw_transaction( + web3: Web3, signed_raw_transaction: HexBytes +) -> HexBytes: + """ + Submits already signed raw transaction. + """ + transaction_hash = web3.eth.send_raw_transaction(signed_raw_transaction) + return transaction_hash + + +def wait_for_transaction_receipt(web3: Web3, transaction_hash: HexBytes): + return web3.eth.wait_for_transaction_receipt(transaction_hash) + + +def deploy_contract( + web3: Web3, + contract_bytecode: str, + contract_abi: List[Dict[str, Any]], + deployer: ChecksumAddress, + deployer_private_key: str, + constructor_arguments: Optional[List[Any]] = None, +) -> Tuple[HexBytes, ChecksumAddress]: + """ + Deploys smart contract to blockchain + Arguments: + - web3: web3 client + - contract_bytecode: Compiled smart contract bytecode + - contract_abi: Json abi of contract. Must include `constructor` function + - deployer: Address which is deploying contract. Deployer will pay transaction fee + - deployer_private_key: Private key of deployer. Needed for signing and submitting transaction + - constructor_arguments: arguments that are passed to `constructor` function of the smart contract + """ + contract = web3.eth.contract(abi=contract_abi, bytecode=contract_bytecode) + if constructor_arguments is None: + transaction = build_transaction(web3, contract.constructor(), deployer) + else: + transaction = build_transaction( + web3, contract.constructor(*constructor_arguments), deployer + ) + + transaction_hash = submit_transaction(web3, transaction, deployer_private_key) + transaction_receipt = wait_for_transaction_receipt(web3, transaction_hash) + contract_address = transaction_receipt.contractAddress + return transaction_hash, web3.toChecksumAddress(contract_address) + + +def deploy_contract_from_constructor_function( + web3: Web3, + contract_bytecode: str, + contract_abi: List[Dict[str, Any]], + deployer: ChecksumAddress, + deployer_private_key: str, + constructor: ContractConstructor, +) -> Tuple[HexBytes, ChecksumAddress]: + """ + Deploys smart contract to blockchain from constructor ContractFunction + Arguments: + - web3: web3 client + - contract_bytecode: Compiled smart contract bytecode + - contract_abi: Json abi of contract. Must include `constructor` function + - deployer: Address which is deploying contract. Deployer will pay transaction fee + - deployer_private_key: Private key of deployer. Needed for signing and submitting transaction + - constructor:`constructor` function of the smart contract + """ + contract = web3.eth.contract(abi=contract_abi, bytecode=contract_bytecode) + transaction = build_transaction( + web3, contract.constructor(*constructor.args), deployer + ) + + transaction_hash = submit_transaction(web3, transaction, deployer_private_key) + transaction_receipt = wait_for_transaction_receipt(web3, transaction_hash) + contract_address = transaction_receipt.contractAddress + return transaction_hash, web3.toChecksumAddress(contract_address) + + +def decode_transaction_input(web3: Web3, transaction_input: str, abi: Dict[str, Any]): + contract = web3.eth.contract(abi=abi) + return contract.decode_function_input(transaction_input) + + +def read_keys_from_cli() -> Tuple[ChecksumAddress, str]: + private_key = getpass.getpass(prompt="Enter private key of your address:") + account = Account.from_key(private_key) + return (Web3.toChecksumAddress(account.address), private_key) + + +def read_keys_from_env() -> Tuple[ChecksumAddress, str]: + private_key = os.environ.get("MOONWORM_ETHEREUM_ADDRESS_PRIVATE_KEY") + if private_key is None: + raise ValueError( + "MOONWORM_ETHEREUM_ADDRESS_PRIVATE_KEY env variable is not set" + ) + try: + account = Account.from_key(private_key) + return (Web3.toChecksumAddress(account.address), private_key) + except: + raise ValueError( + "Failed to initiate account from MOONWORM_ETHEREUM_ADDRESS_PRIVATE_KEY" + ) + + +def connect(web3_uri: str) -> Web3: + web3_provider: Union[IPCProvider, HTTPProvider] = Web3.IPCProvider() + if web3_uri.startswith("http://") or web3_uri.startswith("https://"): + web3_provider = Web3.HTTPProvider(web3_uri) + else: + web3_provider = Web3.IPCProvider(web3_uri) + web3_client = Web3(web3_provider) + return web3_client + + +def read_web3_provider_from_env() -> Web3: + provider_path = os.environ.get("MOONWORM_WEB3_PROVIDER_URI") + if provider_path is None: + raise ValueError("MOONWORM_WEB3_PROVIDER_URI env variable is not set") + return connect(provider_path) + + +def read_web3_provider_from_cli() -> Web3: + provider_path = input("Enter web3 uri path: ") + return connect(provider_path) + + +def cast_to_python_type(evm_type: str) -> Callable: + if evm_type.startswith(("uint", "int")): + return int + elif evm_type.startswith("bytes"): + return bytes + elif evm_type == "string": + return str + elif evm_type == "address": + return Web3.toChecksumAddress + elif evm_type == "bool": + return bool + else: + raise ValueError(f"Cannot convert to python type {evm_type}") diff --git a/engineapi/engineapi/contracts_actions.py b/engineapi/engineapi/contracts_actions.py new file mode 100644 index 00000000..5f859773 --- /dev/null +++ b/engineapi/engineapi/contracts_actions.py @@ -0,0 +1,773 @@ +import argparse +import json +import logging +import uuid +from datetime import timedelta +from typing import Any, Dict, List, Optional + +from sqlalchemy import func, text +from sqlalchemy.exc import IntegrityError, NoResultFound +from sqlalchemy.orm import Session +from web3 import Web3 + +from . import data, db +from .data import ContractType +from .models import CallRequest, RegisteredContract + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class CallRequestNotFound(Exception): + """ + Raised when call request with the given parameters is not found in the database. + """ + + +class InvalidAddressFormat(Exception): + """ + Raised when address not pass web3checksum validation. + """ + + +class ContractAlreadyRegistered(Exception): + pass + + +def validate_method_and_params( + contract_type: ContractType, method: str, parameters: Dict[str, Any] +) -> None: + """ + Validate the given method and parameters for the specified contract_type. + """ + if contract_type == ContractType.raw: + if method != "": + raise ValueError("Method must be empty string for raw contract type") + if set(parameters.keys()) != {"calldata"}: + raise ValueError( + "Parameters must have only 'calldata' key for raw contract type" + ) + elif contract_type == ContractType.dropper: + if method != "claim": + raise ValueError("Method must be 'claim' for dropper contract type") + required_params = { + "dropId", + "requestID", + "blockDeadline", + "amount", + "signer", + "signature", + } + if set(parameters.keys()) != required_params: + raise ValueError( + f"Parameters must have {required_params} keys for dropper contract type" + ) + try: + Web3.toChecksumAddress(parameters["signer"]) + except: + raise InvalidAddressFormat("Parameter signer must be a valid address") + required_params["amount"] = str(required_params["amount"]) + else: + raise ValueError(f"Unknown contract type {contract_type}") + + +def register_contract( + db_session: Session, + blockchain: str, + address: str, + contract_type: ContractType, + moonstream_user_id: uuid.UUID, + title: Optional[str], + description: Optional[str], + image_uri: Optional[str], +) -> data.RegisteredContract: + """ + Register a contract against the Engine instance + """ + try: + contract = RegisteredContract( + blockchain=blockchain, + address=Web3.toChecksumAddress(address), + contract_type=contract_type.value, + moonstream_user_id=moonstream_user_id, + title=title, + description=description, + image_uri=image_uri, + ) + db_session.add(contract) + db_session.commit() + except IntegrityError as err: + db_session.rollback() + raise ContractAlreadyRegistered() + except Exception as err: + db_session.rollback() + logger.error(repr(err)) + raise + + return contract + + +def update_registered_contract( + db_session: Session, + moonstream_user_id: uuid.UUID, + contract_id: uuid.UUID, + title: Optional[str] = None, + description: Optional[str] = None, + image_uri: Optional[str] = None, + ignore_nulls: bool = True, +) -> data.RegisteredContract: + """ + Update the registered contract with the given contract ID provided that the user with moonstream_user_id + has access to it. + """ + query = db_session.query(RegisteredContract).filter( + RegisteredContract.id == contract_id, + RegisteredContract.moonstream_user_id == moonstream_user_id, + ) + + contract = query.one() + + if not (title is None and ignore_nulls): + contract.title = title + if not (description is None and ignore_nulls): + contract.description = description + if not (image_uri is None and ignore_nulls): + contract.image_uri = image_uri + + try: + db_session.add(contract) + db_session.commit() + except Exception as err: + logger.error( + f"update_registered_contract -- error storing update in database: {repr(err)}" + ) + db_session.rollback() + raise + + return contract + + +def get_registered_contract( + db_session: Session, + moonstream_user_id: uuid.UUID, + contract_id: uuid.UUID, +) -> RegisteredContract: + """ + Get registered contract by ID. + """ + contract = ( + db_session.query(RegisteredContract) + .filter(RegisteredContract.moonstream_user_id == moonstream_user_id) + .filter(RegisteredContract.id == contract_id) + .one() + ) + return contract + + +def lookup_registered_contracts( + db_session: Session, + moonstream_user_id: uuid.UUID, + blockchain: Optional[str] = None, + address: Optional[str] = None, + contract_type: Optional[ContractType] = None, + limit: int = 10, + offset: Optional[int] = None, +) -> List[RegisteredContract]: + """ + Lookup a registered contract + """ + query = db_session.query(RegisteredContract).filter( + RegisteredContract.moonstream_user_id == moonstream_user_id + ) + + if blockchain is not None: + query = query.filter(RegisteredContract.blockchain == blockchain) + + if address is not None: + query = query.filter( + RegisteredContract.address == Web3.toChecksumAddress(address) + ) + + if contract_type is not None: + query = query.filter(RegisteredContract.contract_type == contract_type.value) + + if offset is not None: + query = query.offset(offset) + + query = query.limit(limit) + + return query.all() + + +def delete_registered_contract( + db_session: Session, + moonstream_user_id: uuid.UUID, + registered_contract_id: uuid.UUID, +) -> RegisteredContract: + """ + Delete a registered contract + """ + try: + registered_contract = ( + db_session.query(RegisteredContract) + .filter(RegisteredContract.moonstream_user_id == moonstream_user_id) + .filter(RegisteredContract.id == registered_contract_id) + .one() + ) + + db_session.delete(registered_contract) + db_session.commit() + except Exception as err: + db_session.rollback() + logger.error(repr(err)) + raise + + return registered_contract + + +def request_calls( + db_session: Session, + moonstream_user_id: uuid.UUID, + registered_contract_id: Optional[uuid.UUID], + contract_address: Optional[str], + call_specs: List[data.CallSpecification], + ttl_days: Optional[int] = None, +) -> int: + """ + Batch creates call requests for the given registered contract. + """ + # TODO(zomglings): Do not pass raw ttl_days into SQL query - could be subject to SQL injection + # For now, in the interest of speed, let us just be super cautious with ttl_days. + # Check that the ttl_days is indeed an integer + if registered_contract_id is None and contract_address is None: + raise ValueError( + "At least one of registered_contract_id or contract_address is required" + ) + + if ttl_days is not None: + assert ttl_days == int(ttl_days), "ttl_days must be an integer" + if ttl_days <= 0: + raise ValueError("ttl_days must be positive") + + # Check that the moonstream_user_id matches a RegisteredContract with the given id or address + query = db_session.query(RegisteredContract).filter( + RegisteredContract.moonstream_user_id == moonstream_user_id + ) + + if registered_contract_id is not None: + query = query.filter(RegisteredContract.id == registered_contract_id) + + if contract_address is not None: + query = query.filter( + RegisteredContract.address == Web3.toChecksumAddress(contract_address) + ) + + try: + registered_contract = query.one() + except NoResultFound: + raise ValueError("Invalid registered_contract_id or moonstream_user_id") + + # Normalize the caller argument using Web3.toChecksumAddress + contract_type = ContractType(registered_contract.contract_type) + for specification in call_specs: + normalized_caller = Web3.toChecksumAddress(specification.caller) + + # Validate the method and parameters for the contract_type + try: + validate_method_and_params( + contract_type, specification.method, specification.parameters + ) + except InvalidAddressFormat as err: + raise InvalidAddressFormat(err) + except Exception as err: + logger.error( + f"Unhandled error occurred during methods and parameters validation, err: {err}" + ) + + expires_at = None + if ttl_days is not None: + expires_at = func.now() + timedelta(days=ttl_days) + + request = CallRequest( + registered_contract_id=registered_contract.id, + caller=normalized_caller, + moonstream_user_id=moonstream_user_id, + method=specification.method, + parameters=specification.parameters, + expires_at=expires_at, + ) + + db_session.add(request) + # Insert the new rows into the database in a single transaction + try: + db_session.commit() + except Exception as e: + db_session.rollback() + raise e + + return len(call_specs) + + +def get_call_requests( + db_session: Session, + request_id: uuid.UUID, +) -> data.CallRequest: + """ + Get call request by ID. + """ + results = ( + db_session.query(CallRequest, RegisteredContract) + .join( + RegisteredContract, + CallRequest.registered_contract_id == RegisteredContract.id, + ) + .filter(CallRequest.id == request_id) + .all() + ) + if len(results) == 0: + raise CallRequestNotFound("Call request with given ID not found") + elif len(results) != 1: + raise Exception( + f"Incorrect number of results found for moonstream_user_id {moonstream_user_id} and request_id {request_id}" + ) + return data.CallRequest( + contract_address=results[0][1].address, **results[0][0].__dict__ + ) + + +def list_call_requests( + db_session: Session, + contract_id: Optional[uuid.UUID], + contract_address: Optional[str], + caller: Optional[str], + limit: int = 10, + offset: Optional[int] = None, + show_expired: bool = False, +) -> List[data.CallRequest]: + """ + List call requests for the given moonstream_user_id + """ + if caller is None: + raise ValueError("caller must be specified") + + if contract_id is None and contract_address is None: + raise ValueError( + "At least one of contract_id or contract_address must be specified" + ) + + # If show_expired is False, filter out expired requests using current time on database server + query = ( + db_session.query(CallRequest, RegisteredContract) + .join( + RegisteredContract, + CallRequest.registered_contract_id == RegisteredContract.id, + ) + .filter(CallRequest.caller == Web3.toChecksumAddress(caller)) + ) + + if contract_id is not None: + query = query.filter(CallRequest.registered_contract_id == contract_id) + + if contract_address is not None: + query = query.filter( + RegisteredContract.address == Web3.toChecksumAddress(contract_address) + ) + + if not show_expired: + query = query.filter( + CallRequest.expires_at > func.now(), + ) + + if offset is not None: + query = query.offset(offset) + + query = query.limit(limit) + results = query.all() + return [ + data.CallRequest( + contract_address=registered_contract.address, **call_request.__dict__ + ) + for call_request, registered_contract in results + ] + + +# TODO(zomglings): What should the delete functionality for call requests look like? +# - Delete expired requests for a given caller? +# - Delete all requests for a given caller? +# - Delete all requests for a given contract? +# - Delete request by ID? +# Should we implement these all using a single delete method, or a different method for each +# use case? +# Will come back to this once API is live. + + +def delete_requests( + db_session: Session, + moonstream_user_id: uuid.UUID, + request_ids: List[uuid.UUID] = [], +) -> int: + """ + Delete a requests. + """ + try: + requests_to_delete_query = ( + db_session.query(CallRequest) + .filter(CallRequest.moonstream_user_id == moonstream_user_id) + .filter(CallRequest.id.in_(request_ids)) + ) + requests_to_delete_num: int = requests_to_delete_query.delete( + synchronize_session=False + ) + db_session.commit() + except Exception as err: + db_session.rollback() + logger.error(repr(err)) + raise Exception("Failed to delete call requests") + + return requests_to_delete_num + + +def handle_register(args: argparse.Namespace) -> None: + """ + Handles the register command. + """ + try: + with db.yield_db_session_ctx() as db_session: + contract = register_contract( + db_session=db_session, + blockchain=args.blockchain, + address=args.address, + contract_type=args.contract_type, + moonstream_user_id=args.user_id, + title=args.title, + description=args.description, + image_uri=args.image_uri, + ) + except Exception as err: + logger.error(err) + return + print(contract.json()) + + +def handle_list(args: argparse.Namespace) -> None: + """ + Handles the list command. + """ + try: + with db.yield_db_session_ctx() as db_session: + contracts = lookup_registered_contracts( + db_session=db_session, + moonstream_user_id=args.user_id, + blockchain=args.blockchain, + address=args.address, + contract_type=args.contract_type, + limit=args.limit, + offset=args.offset, + ) + except Exception as err: + logger.error(err) + return + + print(json.dumps([contract.dict() for contract in contracts])) + + +def handle_delete(args: argparse.Namespace) -> None: + """ + Handles the delete command. + """ + try: + with db.yield_db_session_ctx() as db_session: + deleted_contract = delete_registered_contract( + db_session=db_session, + registered_contract_id=args.id, + moonstream_user_id=args.user_id, + ) + except Exception as err: + logger.error(err) + return + + print(deleted_contract.json()) + + +def handle_request_calls(args: argparse.Namespace) -> None: + """ + Handles the request-calls command. + + Reads a file of JSON-formatted call specifications from `args.call_specs`, + validates them, and adds them to the call_requests table in the Engine database. + + :param args: The arguments passed to the CLI command. + """ + with args.call_specs as ifp: + try: + call_specs_raw = json.load(ifp) + except Exception as e: + logger.error(f"Failed to load call specs: {e}") + return + + call_specs = [data.CallSpecification(**spec) for spec in call_specs_raw] + + try: + with db.yield_db_session_ctx() as db_session: + request_calls( + db_session=db_session, + moonstream_user_id=args.moonstream_user_id, + registered_contract_id=args.registered_contract_id, + call_specs=call_specs, + ttl_days=args.ttl_days, + ) + except Exception as e: + logger.error(f"Failed to request calls: {e}") + return + + +def handle_list_requests(args: argparse.Namespace) -> None: + """ + Handles the requests command. + + :param args: The arguments passed to the CLI command. + """ + try: + with db.yield_db_session_ctx() as db_session: + call_requests = list_call_requests( + db_session=db_session, + contract_id=args.registered_contract_id, + caller=args.caller, + limit=args.limit, + offset=args.offset, + show_expired=args.show_expired, + ) + except Exception as e: + logger.error(f"Failed to list call requests: {e}") + return + + print(json.dumps([request.dict() for request in call_requests])) + + +def generate_cli() -> argparse.ArgumentParser: + """ + Generates a CLI which can be used to manage registered contracts on an Engine instance. + """ + parser = argparse.ArgumentParser(description="Manage registered contracts") + parser.set_defaults(func=lambda _: parser.print_help()) + subparsers = parser.add_subparsers() + + register_usage = "Register a new contract" + register_parser = subparsers.add_parser( + "register", help=register_usage, description=register_usage + ) + register_parser.add_argument( + "-b", + "--blockchain", + type=str, + required=True, + help="The blockchain the contract is deployed on", + ) + register_parser.add_argument( + "-a", + "--address", + type=str, + required=True, + help="The address of the contract", + ) + register_parser.add_argument( + "-c", + "--contract-type", + type=ContractType, + choices=ContractType, + required=True, + help="The type of the contract", + ) + register_parser.add_argument( + "-u", + "--user-id", + type=uuid.UUID, + required=True, + help="The ID of the Moonstream user under whom to register the contract", + ) + register_parser.add_argument( + "-t", + "--title", + type=str, + required=False, + default=None, + help="The title of the contract", + ) + register_parser.add_argument( + "-d", + "--description", + type=str, + required=False, + default=None, + help="The description of the contract", + ) + register_parser.add_argument( + "-i", + "--image-uri", + type=str, + required=False, + default=None, + help="The image URI of the contract", + ) + register_parser.set_defaults(func=handle_register) + + list_contracts_usage = "List all contracts matching certain criteria" + list_contracts_parser = subparsers.add_parser( + "list", help=list_contracts_usage, description=list_contracts_usage + ) + list_contracts_parser.add_argument( + "-b", + "--blockchain", + type=str, + required=False, + default=None, + help="The blockchain the contract is deployed on", + ) + list_contracts_parser.add_argument( + "-a", + "--address", + type=str, + required=False, + default=None, + help="The address of the contract", + ) + list_contracts_parser.add_argument( + "-c", + "--contract-type", + type=ContractType, + choices=ContractType, + required=False, + default=None, + help="The type of the contract", + ) + list_contracts_parser.add_argument( + "-u", + "--user-id", + type=uuid.UUID, + required=True, + help="The ID of the Moonstream user whose contracts to list", + ) + list_contracts_parser.add_argument( + "-N", + "--limit", + type=int, + required=False, + default=10, + help="The number of contracts to return", + ) + list_contracts_parser.add_argument( + "-n", + "--offset", + type=int, + required=False, + default=0, + help="The offset to start returning contracts from", + ) + list_contracts_parser.set_defaults(func=handle_list) + + delete_usage = "Delete a registered contract from an Engine instance" + delete_parser = subparsers.add_parser( + "delete", help=delete_usage, description=delete_usage + ) + delete_parser.add_argument( + "--id", + type=uuid.UUID, + required=True, + help="The ID of the contract to delete", + ) + delete_parser.add_argument( + "-u", + "--user-id", + type=uuid.UUID, + required=True, + help="The ID of the Moonstream user whose contract to delete", + ) + delete_parser.set_defaults(func=handle_delete) + + request_calls_usage = "Create call requests for a registered contract" + request_calls_parser = subparsers.add_parser( + "request-calls", help=request_calls_usage, description=request_calls_usage + ) + request_calls_parser.add_argument( + "-i", + "--registered-contract-id", + type=uuid.UUID, + required=True, + help="The ID of the registered contract to create call requests for", + ) + request_calls_parser.add_argument( + "-u", + "--moonstream-user-id", + type=uuid.UUID, + required=True, + help="The ID of the Moonstream user who owns the contract", + ) + request_calls_parser.add_argument( + "-c", + "--calls", + type=argparse.FileType("r"), + required=True, + help="Path to the JSON file with call specifications", + ) + request_calls_parser.add_argument( + "-t", + "--ttl-days", + type=int, + required=False, + default=None, + help="The number of days until the call requests expire", + ) + request_calls_parser.set_defaults(func=handle_request_calls) + + list_requests_usage = "List requests for calls on a registered contract" + list_requests_parser = subparsers.add_parser( + "requests", help=list_requests_usage, description=list_requests_usage + ) + list_requests_parser.add_argument( + "-i", + "--registered-contract-id", + type=uuid.UUID, + required=True, + help="The ID of the registered contract to list call requests for", + ) + list_requests_parser.add_argument( + "-c", + "--caller", + type=Web3.toChecksumAddress, + required=True, + help="Caller's address", + ) + list_requests_parser.add_argument( + "-N", + "--limit", + type=int, + required=False, + default=10, + help="The number of call requests to return", + ) + list_requests_parser.add_argument( + "-n", + "--offset", + type=int, + required=False, + default=0, + help="The offset to start returning contracts from", + ) + list_requests_parser.add_argument( + "--show-expired", + action="store_true", + help="Set this flag to also show expired requests. Default behavior is to hide these.", + ) + list_requests_parser.set_defaults(func=handle_list_requests) + + return parser + + +def main() -> None: + parser = generate_cli() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/engineapi/engineapi/data.py b/engineapi/engineapi/data.py new file mode 100644 index 00000000..b005ca9d --- /dev/null +++ b/engineapi/engineapi/data.py @@ -0,0 +1,307 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, root_validator, validator +from web3 import Web3 + + +class PingResponse(BaseModel): + """ + Schema for ping response + """ + + status: str + + +class NowResponse(BaseModel): + """ + Schema for responses on /now endpoint + """ + + epoch_time: float + + +class SignerListResponse(BaseModel): + instances: List[Any] = Field(default_factory=list) + + +class SignerSleepResponse(BaseModel): + instances: List[str] = Field(default_factory=list) + + +class SignerWakeupResponse(BaseModel): + instances: List[str] = Field(default_factory=list) + + +class DropperContractResponse(BaseModel): + id: UUID + address: str + blockchain: str + title: Optional[str] + description: Optional[str] + image_uri: Optional[str] + + +class DropperTerminusResponse(BaseModel): + terminus_address: str + terminus_pool_id: int + blockchain: str + + +class DropperBlockchainResponse(BaseModel): + blockchain: str + + +class DropRegisterRequest(BaseModel): + dropper_contract_id: UUID + title: Optional[str] = None + description: Optional[str] = None + claim_block_deadline: Optional[int] = None + terminus_address: Optional[str] = None + terminus_pool_id: Optional[int] = None + claim_id: Optional[int] = None + + +class DropCreatedResponse(BaseModel): + dropper_claim_id: UUID + dropper_contract_id: UUID + title: str + description: str + claim_block_deadline: Optional[int] = None + terminus_address: Optional[str] = None + terminus_pool_id: Optional[int] = None + claim_id: Optional[int] = None + + +class Claimant(BaseModel): + address: str + amount: int + raw_amount: Optional[str] = None + + +class BatchAddClaimantsRequest(BaseModel): + claimants: List[Claimant] = Field(default_factory=list) + + +class BatchRemoveClaimantsRequest(BaseModel): + claimants: List[str] = Field(default_factory=list) + + +class DropAddClaimantsRequest(BaseModel): + dropper_claim_id: UUID + claimants: List[Claimant] = Field(default_factory=list) + + +class ClaimantsResponse(BaseModel): + claimants: List[Claimant] = Field(default_factory=list) + + +class DropRemoveClaimantsRequest(BaseModel): + dropper_claim_id: UUID + addresses: List[str] = Field(default_factory=list) + + +class RemoveClaimantsResponse(BaseModel): + addresses: List[str] = Field(default_factory=list) + + +class DropperClaimResponse(BaseModel): + id: UUID + dropper_contract_id: UUID + title: str + description: str + active: bool + claim_block_deadline: Optional[int] = None + terminus_address: Optional[str] = None + terminus_pool_id: Optional[int] = None + claim_id: Optional[int] = None + + +class DropResponse(BaseModel): + claimant: str + claim_id: int + amount: str + block_deadline: int + signature: str + title: str + description: str + + +class DropBatchResponseItem(BaseModel): + claimant: str + claim_id: int + title: str + description: str + amount: int + amount_string: str + block_deadline: int + signature: str + dropper_claim_id: UUID + dropper_contract_address: str + blockchain: str + + +class DropListResponse(BaseModel): + drops: List[Any] = Field(default_factory=list) + + +class DropClaimant(BaseModel): + amount: Optional[int] + added_by: Optional[str] + address: Optional[str] + + +class DropActivateRequest(BaseModel): + dropper_claim_id: UUID + + +class DropUpdateRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + claim_block_deadline: Optional[int] = None + terminus_address: Optional[str] = None + terminus_pool_id: Optional[int] = None + claim_id: Optional[int] = None + + +class DropUpdatedResponse(BaseModel): + dropper_claim_id: UUID + dropper_contract_id: UUID + title: str + description: str + claim_block_deadline: Optional[int] = None + terminus_address: Optional[str] = None + terminus_pool_id: Optional[int] = None + claim_id: Optional[int] = None + active: bool = True + + +class ContractType(Enum): + raw = "raw" + dropper = "dropper-v0.2.0" + + +class RegisterContractRequest(BaseModel): + blockchain: str + address: str + contract_type: ContractType + title: Optional[str] = None + description: Optional[str] = None + image_uri: Optional[str] = None + + +class UpdateContractRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + image_uri: Optional[str] = None + ignore_nulls: bool = True + + +class RegisteredContract(BaseModel): + id: UUID + blockchain: str + address: str + contract_type: str + moonstream_user_id: UUID + title: Optional[str] = None + description: Optional[str] = None + image_uri: Optional[str] = None + created_at: datetime + updated_at: datetime + + @validator("id", "moonstream_user_id") + def validate_uuids(cls, v): + return str(v) + + @validator("created_at", "updated_at") + def validate_datetimes(cls, v): + return v.isoformat() + + class Config: + orm_mode = True + + +class CallSpecification(BaseModel): + caller: str + method: str + parameters: Dict[str, Any] + + @validator("caller") + def validate_web3_addresses(cls, v): + return Web3.toChecksumAddress(v) + + +class CreateCallRequestsAPIRequest(BaseModel): + contract_id: Optional[UUID] = None + contract_address: Optional[str] = None + specifications: List[CallSpecification] = Field(default_factory=list) + ttl_days: Optional[int] = None + + # Solution found thanks to https://github.com/pydantic/pydantic/issues/506 + @root_validator + def at_least_one_of_contract_id_and_contract_address(cls, values): + if values.get("contract_id") is None and values.get("contract_address") is None: + raise ValueError( + "At least one of contract_id and contract_address must be provided" + ) + return values + + +class CallRequest(BaseModel): + id: UUID + contract_id: UUID = Field(alias="registered_contract_id") + contract_address: Optional[str] = None + moonstream_user_id: UUID + caller: str + method: str + parameters: Dict[str, Any] + expires_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + @validator("id", "contract_id", "moonstream_user_id") + def validate_uuids(cls, v): + return str(v) + + @validator("created_at", "updated_at", "expires_at") + def validate_datetimes(cls, v): + if v is not None: + return v.isoformat() + + @validator("contract_address", "caller") + def validate_web3_adresses(cls, v): + return Web3.toChecksumAddress(v) + + +class QuartilesResponse(BaseModel): + percentile_25: Dict[str, Any] + percentile_50: Dict[str, Any] + percentile_75: Dict[str, Any] + + +class CountAddressesResponse(BaseModel): + count: int = Field(default_factory=int) + + +class Score(BaseModel): + address: str + score: int + points_data: Dict[str, Any] + + +class LeaderboardPosition(BaseModel): + address: str + rank: int + score: int + points_data: Dict[str, Any] + + +class RanksResponse(BaseModel): + rank: int + score: int + size: int diff --git a/engineapi/engineapi/db.py b/engineapi/engineapi/db.py new file mode 100644 index 00000000..55c5c127 --- /dev/null +++ b/engineapi/engineapi/db.py @@ -0,0 +1,84 @@ +""" +Engine database connection. +""" +from contextlib import contextmanager +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +from .settings import ( + ENGINE_DB_URI, + ENGINE_DB_URI_READ_ONLY, + ENGINE_POOL_SIZE, + ENGINE_DB_STATEMENT_TIMEOUT_MILLIS, + ENGINE_DB_POOL_RECYCLE_SECONDS, +) + + +def create_local_engine( + url: Optional[str], + pool_size: int, + statement_timeout: int, + pool_recycle: int, +): + # Pooling: https://docs.sqlalchemy.org/en/14/core/pooling.html#sqlalchemy.pool.QueuePool + # Statement timeout: https://stackoverflow.com/a/44936982 + return create_engine( + url=url, + pool_size=pool_size, + pool_recycle=pool_recycle, + connect_args={"options": f"-c statement_timeout={statement_timeout}"}, + ) + + +engine = create_local_engine( + url=ENGINE_DB_URI, + pool_size=ENGINE_POOL_SIZE, + statement_timeout=ENGINE_DB_STATEMENT_TIMEOUT_MILLIS, + pool_recycle=ENGINE_DB_POOL_RECYCLE_SECONDS, +) + +SessionLocal = sessionmaker(bind=engine) + + +def yield_db_session() -> Session: + """ + Yields a database connection (created using environment variables). + As per FastAPI docs: + https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency + """ + session = SessionLocal() + try: + yield session + finally: + session.close() + + +yield_db_session_ctx = contextmanager(yield_db_session) + +# Read only connection +RO_engine = create_local_engine( + url=ENGINE_DB_URI_READ_ONLY, + pool_size=ENGINE_POOL_SIZE, + statement_timeout=ENGINE_DB_STATEMENT_TIMEOUT_MILLIS, + pool_recycle=ENGINE_DB_POOL_RECYCLE_SECONDS, +) + +RO_SessionLocal = sessionmaker(bind=RO_engine) + + +def yield_db_read_only_session() -> Session: + """ + Yields read only database connection (created using environment variables). + As per FastAPI docs: + https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency + """ + session = RO_SessionLocal() + try: + yield session + finally: + session.close() + + +yield_db_read_only_session_ctx = contextmanager(yield_db_read_only_session) diff --git a/engineapi/engineapi/middleware.py b/engineapi/engineapi/middleware.py new file mode 100644 index 00000000..9e4cc570 --- /dev/null +++ b/engineapi/engineapi/middleware.py @@ -0,0 +1,201 @@ +import base64 +import json +import logging +from typing import Any, Awaitable, Callable, Dict, Optional + +from bugout.data import BugoutUser +from bugout.exceptions import BugoutResponseException +from fastapi import HTTPException, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from web3 import Web3 + +from .auth import ( + MoonstreamAuthorizationExpired, + MoonstreamAuthorizationVerificationError, + verify, +) +from .settings import bugout_client as bc, MOONSTREAM_APPLICATION_ID + +logger = logging.getLogger(__name__) + + +class BroodAuthMiddleware(BaseHTTPMiddleware): + """ + Checks the authorization header on the request. If it represents a verified Brood user, + create another request and get groups user belongs to, after this + adds a brood_user attribute to the request.state. Otherwise raises a 403 error. + + Taken almost verbatim from the Moonstream repo: + https://github.com/bugout-dev/moonstream/blob/99504a431acdd903259d1c4014a2808ce5a104c1/backend/moonstreamapi/middleware.py + """ + + def __init__(self, app, whitelist: Optional[Dict[str, str]] = None): + self.whitelist: Dict[str, str] = {} + if whitelist is not None: + self.whitelist = whitelist + super().__init__(app) + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ): + # Filter out endpoints with proper method to work without Bearer token (as create_user, login, etc) + path = request.url.path.rstrip("/") + method = request.method + if path in self.whitelist.keys() and self.whitelist[path] == method: + return await call_next(request) + + authorization_header = request.headers.get("authorization") + if authorization_header is None: + return Response( + status_code=403, content="No authorization header passed with request" + ) + user_token_list = authorization_header.split() + if len(user_token_list) != 2: + return Response(status_code=403, content="Wrong authorization header") + user_token: str = user_token_list[-1] + + try: + user: BugoutUser = bc.get_user(user_token) + if not user.verified: + logger.info( + f"Attempted journal access by unverified Brood account: {user.id}" + ) + return Response( + status_code=403, + content="Only verified accounts can access journals", + ) + if str(user.application_id) != str(MOONSTREAM_APPLICATION_ID): + return Response( + status_code=403, content="User does not belong to this application" + ) + except BugoutResponseException as e: + return Response(status_code=e.status_code, content=e.detail) + except Exception as e: + logger.error(f"Error processing Brood response: {str(e)}") + return Response(status_code=500, content="Internal server error") + + request.state.user = user + request.state.token = user_token + return await call_next(request) + + +class EngineAuthMiddleware(BaseHTTPMiddleware): + """ + Checks the authorization header on the request. It it represents + a correctly signer message, adds address and deadline attributes to the request.state. + Otherwise raises a 403 error. + """ + + def __init__(self, app, whitelist: Optional[Dict[str, str]] = None): + self.whitelist: Dict[str, str] = {} + if whitelist is not None: + self.whitelist = whitelist + super().__init__(app) + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ): + # Filter out whitelisted endpoints without web3 authorization + path = request.url.path.rstrip("/") + method = request.method + + if path in self.whitelist.keys() and self.whitelist[path] == method: + return await call_next(request) + + raw_authorization_header = request.headers.get("authorization") + + if raw_authorization_header is None: + return Response( + status_code=403, content="No authorization header passed with request" + ) + + authorization_header_components = raw_authorization_header.split() + if ( + len(authorization_header_components) != 2 + or authorization_header_components[0].lower() != "moonstream" + ): + return Response( + status_code=403, + content="Incorrect format for authorization header. Expected 'Authorization: moonstream '", + ) + + try: + json_payload_str = base64.b64decode( + authorization_header_components[-1] + ).decode("utf-8") + + json_payload = json.loads(json_payload_str) + verified = verify(json_payload) + address = json_payload.get("address") + if address is not None: + address = Web3.toChecksumAddress(address) + else: + raise Exception("Address in payload is None") + except MoonstreamAuthorizationVerificationError as e: + logger.info("Moonstream authorization verification error: %s", e) + return Response(status_code=403, content="Invalid authorization header") + except MoonstreamAuthorizationExpired as e: + logger.info("Moonstream authorization expired: %s", e) + return Response(status_code=403, content="Authorization expired") + except Exception as e: + logger.error("Unexpected exception: %s", e) + return Response(status_code=500, content="Internal server error") + + request.state.address = address + request.state.verified = verified + + return await call_next(request) + + +class EngineHTTPException(HTTPException): + """ + Extended HTTPException to handle 500 Internal server errors + and send crash reports. + """ + + def __init__( + self, + status_code: int, + detail: Any = None, + headers: Optional[Dict[str, Any]] = None, + internal_error: Exception = None, + ): + super().__init__(status_code, detail, headers) + if internal_error is not None: + print(internal_error) + # reporter.error_report(internal_error) + + +class ExtractBearerTokenMiddleware(BaseHTTPMiddleware): + """ + Checks the authorization header on the request and extract token. + """ + + def __init__(self, app, whitelist: Optional[Dict[str, str]] = None): + self.whitelist: Dict[str, str] = {} + if whitelist is not None: + self.whitelist = whitelist + super().__init__(app) + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ): + # Filter out endpoints with proper method to work without Bearer token (as create_user, login, etc) + path = request.url.path.rstrip("/") + method = request.method + if path in self.whitelist.keys() and self.whitelist[path] == method: + return await call_next(request) + + authorization_header = request.headers.get("authorization") + if authorization_header is None: + return Response( + status_code=403, content="No authorization header passed with request" + ) + authorization_header_components = authorization_header.split() + if len(authorization_header_components) != 2: + return Response(status_code=403, content="Wrong authorization header") + user_token: str = authorization_header_components[-1] + + request.state.token = user_token + + return await call_next(request) diff --git a/engineapi/engineapi/models.py b/engineapi/engineapi/models.py new file mode 100644 index 00000000..2518be42 --- /dev/null +++ b/engineapi/engineapi/models.py @@ -0,0 +1,287 @@ +import uuid + +from sqlalchemy import ( + VARCHAR, + BigInteger, + Boolean, + Column, + DateTime, + ForeignKey, + Index, + MetaData, + String, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.sql import and_, expression + +""" +Naming conventions doc +https://docs.sqlalchemy.org/en/13/core/constraints.html#configuring-constraint-naming-conventions +""" +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} +metadata = MetaData(naming_convention=convention) +Base = declarative_base(metadata=metadata) + +""" +Creating a utcnow function which runs on the Posgres database server when created_at and updated_at +fields are populated. +Following: +1. https://docs.sqlalchemy.org/en/13/core/compiler.html#utc-timestamp-function +2. https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT +3. https://stackoverflow.com/a/33532154/13659585 +""" + + +class utcnow(expression.FunctionElement): + type = DateTime + + +@compiles(utcnow, "postgresql") +def pg_utcnow(element, compiler, **kwargs): + return "TIMEZONE('utc', statement_timestamp())" + + +class DropperContract(Base): # type: ignore + __tablename__ = "dropper_contracts" + __table_args__ = (UniqueConstraint("blockchain", "address"),) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + ) + blockchain = Column(VARCHAR(128), nullable=False) + address = Column(VARCHAR(256), index=True) + title = Column(VARCHAR(128), nullable=True) + description = Column(String, nullable=True) + image_uri = Column(String, nullable=True) + + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + +class DropperClaim(Base): # type: ignore + __tablename__ = "dropper_claims" + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + ) + dropper_contract_id = Column( + UUID(as_uuid=True), + ForeignKey("dropper_contracts.id", ondelete="CASCADE"), + nullable=False, + ) + claim_id = Column(BigInteger, nullable=True) + title = Column(VARCHAR(128), nullable=True) + description = Column(String, nullable=True) + terminus_address = Column(VARCHAR(256), nullable=True, index=True) + terminus_pool_id = Column(BigInteger, nullable=True, index=True) + claim_block_deadline = Column(BigInteger, nullable=True) + active = Column(Boolean, default=False, nullable=False) + + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + __table_args__ = ( + Index( + "uq_dropper_claims_dropper_contract_id_claim_id", + "dropper_contract_id", + "claim_id", + unique=True, + postgresql_where=and_(claim_id.isnot(None), active.is_(True)), + ), + ) + + +class DropperClaimant(Base): # type: ignore + __tablename__ = "dropper_claimants" + __table_args__ = (UniqueConstraint("dropper_claim_id", "address"),) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + ) + dropper_claim_id = Column( + UUID(as_uuid=True), + ForeignKey("dropper_claims.id", ondelete="CASCADE"), + nullable=False, + ) + address = Column(VARCHAR(256), nullable=False, index=True) + amount = Column(BigInteger, nullable=False) + raw_amount = Column(String, nullable=True) + added_by = Column(VARCHAR(256), nullable=False, index=True) + signature = Column(String, nullable=True, index=True) + + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + +class RegisteredContract(Base): # type: ignore + __tablename__ = "registered_contracts" + __table_args__ = ( + UniqueConstraint( + "blockchain", + "moonstream_user_id", + "address", + "contract_type", + ), + ) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + ) + blockchain = Column(VARCHAR(128), nullable=False, index=True) + address = Column(VARCHAR(256), nullable=False, index=True) + contract_type = Column(VARCHAR(128), nullable=False, index=True) + title = Column(VARCHAR(128), nullable=False) + description = Column(String, nullable=True) + image_uri = Column(String, nullable=True) + # User ID of the Moonstream user who registered this contract. + moonstream_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + +class CallRequest(Base): + __tablename__ = "call_requests" + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + ) + + registered_contract_id = Column( + UUID(as_uuid=True), + ForeignKey("registered_contracts.id", ondelete="CASCADE"), + nullable=False, + ) + caller = Column(VARCHAR(256), nullable=False, index=True) + # User ID of the Moonstream user who requested this call. + # For now, this duplicates the moonstream_user_id in the registered_contracts table. Nevertheless, + # we keep this column here for auditing purposes. In the future, we will add a group_id column to + # the registered_contracts table, and this column will be used to track the user from that group + # who made each call request. + moonstream_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + method = Column(String, nullable=False, index=True) + # TODO(zomglings): Should we conditional indices on parameters depending on the contract type? + parameters = Column(JSONB, nullable=False) + + expires_at = Column(DateTime(timezone=True), nullable=True, index=True) + + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + +class Leaderboard(Base): # type: ignore + __tablename__ = "leaderboards" + # __table_args__ = (UniqueConstraint("dropper_contract_id", "address"),) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + ) + title = Column(VARCHAR(128), nullable=False) + description = Column(String, nullable=True) + resource_id = Column(UUID(as_uuid=True), nullable=True, index=True) + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + +class LeaderboardScores(Base): # type: ignore + __tablename__ = "leaderboard_scores" + __table_args__ = (UniqueConstraint("leaderboard_id", "address"),) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + ) + leaderboard_id = Column( + UUID(as_uuid=True), + ForeignKey("leaderboards.id", ondelete="CASCADE"), + nullable=False, + ) + address = Column(VARCHAR(256), nullable=False, index=True) + score = Column(BigInteger, nullable=False) + points_data = Column(JSONB, nullable=True) + created_at = Column( + DateTime(timezone=True), server_default=utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) diff --git a/engineapi/engineapi/routes/__init__.py b/engineapi/engineapi/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engineapi/engineapi/routes/admin.py b/engineapi/engineapi/routes/admin.py new file mode 100644 index 00000000..c9d2785e --- /dev/null +++ b/engineapi/engineapi/routes/admin.py @@ -0,0 +1,520 @@ +""" +Moonstream Engine Admin API. +""" +import logging +from typing import Optional, Any, Dict +from uuid import UUID + +from web3 import Web3 +from fastapi import Body, FastAPI, Request, Depends, Query +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound + +from .. import actions +from .. import data +from .. import db +from ..middleware import EngineHTTPException, EngineAuthMiddleware +from ..settings import DOCS_TARGET_PATH, ORIGINS +from ..version import VERSION + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +tags_metadata = [{"name": "admin", "description": "Moonstream Engine Admin API"}] + +whitelist_paths: Dict[str, str] = {} +whitelist_paths.update( + { + "/admin/docs": "GET", + "/admin/openapi.json": "GET", + } +) + +app = FastAPI( + title=f"Moonstream Engine Admin API", + description="Moonstream Engine Admin API endpoints.", + version=VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + + +app.add_middleware(EngineAuthMiddleware, whitelist=whitelist_paths) + +app.add_middleware( + CORSMiddleware, + allow_origins=ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/drops", response_model=data.DropListResponse) +async def get_drop_list_handler( + request: Request, + blockchain: str, + contract_address: str, + drop_number: Optional[int] = Query(None), + terminus_address: Optional[str] = Query(None), + terminus_pool_id: Optional[int] = Query(None), + active: Optional[bool] = Query(None), + limit: int = 20, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropListResponse: + """ + Get list of drops for a given dropper contract and drop number. + """ + + contract_address = Web3.toChecksumAddress(contract_address) + + # try: + # actions.ensure_contract_admin_token_holder( + # blockchain, contract_address, request.state.address + # ) + # except actions.AuthorizationError as e: + # logger.error(e) + # raise EngineHTTPException(status_code=403) + # except NoResultFound: + # raise EngineHTTPException(status_code=404, detail="Drop not found") + + if terminus_address: + terminus_address = Web3.toChecksumAddress(terminus_address) + + try: + results = actions.get_drops( + db_session=db_session, + dropper_contract_address=contract_address, + blockchain=blockchain, + drop_number=drop_number, + terminus_address=terminus_address, + terminus_pool_id=terminus_pool_id, + active=active, + limit=limit, + offset=offset, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get drops. Failed with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get claims") + + return data.DropListResponse(drops=[result for result in results]) + + +@app.post("/drops", response_model=data.DropCreatedResponse) +async def create_drop( + request: Request, + register_request: data.DropRegisterRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.DropCreatedResponse: + + """ + Create a drop for a given dropper contract. + """ + try: + actions.ensure_dropper_contract_owner( + db_session, register_request.dropper_contract_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Dropper contract not found") + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + if register_request.terminus_address: + register_request.terminus_address = Web3.toChecksumAddress( + register_request.terminus_address + ) + + try: + claim = actions.create_claim( + db_session=db_session, + dropper_contract_id=register_request.dropper_contract_id, + title=register_request.title, + description=register_request.description, + claim_block_deadline=register_request.claim_block_deadline, + terminus_address=register_request.terminus_address, + terminus_pool_id=register_request.terminus_pool_id, + claim_id=register_request.claim_id, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Dropper contract not found") + except Exception as e: + logger.error(f"Can't create claim: {e}") + raise EngineHTTPException(status_code=500, detail="Can't create claim") + + return data.DropCreatedResponse( + dropper_claim_id=claim.id, + dropper_contract_id=claim.dropper_contract_id, + title=claim.title, + description=claim.description, + claim_block_deadline=claim.claim_block_deadline, + terminus_address=claim.terminus_address, + terminus_pool_id=claim.terminus_pool_id, + claim_id=claim.claim_id, + ) + + +@app.put( + "/drops/{dropper_claim_id}/activate", + response_model=data.DropUpdatedResponse, +) +async def activate_drop( + request: Request, + dropper_claim_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropUpdatedResponse: + + """ + Activate a given drop by drop id. + """ + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + try: + drop = actions.activate_drop( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + except Exception as e: + logger.error(f"Can't activate drop: {e}") + raise EngineHTTPException(status_code=500, detail="Can't activate drop") + + return data.DropUpdatedResponse( + dropper_claim_id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + active=drop.active, + ) + + +@app.put( + "/drops/{dropper_claim_id}/deactivate", + response_model=data.DropUpdatedResponse, +) +async def deactivate_drop( + request: Request, + dropper_claim_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropUpdatedResponse: + + """ + Activate a given drop by drop id. + """ + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + try: + drop = actions.deactivate_drop( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + except Exception as e: + logger.error(f"Can't activate drop: {e}") + raise EngineHTTPException(status_code=500, detail="Can't activate drop") + + return data.DropUpdatedResponse( + dropper_claim_id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + active=drop.active, + ) + + +@app.patch("/drops/{dropper_claim_id}", response_model=data.DropUpdatedResponse) +async def update_drop( + request: Request, + dropper_claim_id: UUID, + update_request: data.DropUpdateRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.DropUpdatedResponse: + + """ + Update a given drop by drop id. + """ + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + try: + drop = actions.update_drop( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + title=update_request.title, + description=update_request.description, + claim_block_deadline=update_request.claim_block_deadline, + terminus_address=update_request.terminus_address, + terminus_pool_id=update_request.terminus_pool_id, + claim_id=update_request.claim_id, + address=request.state.address, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + except Exception as e: + logger.error(f"Can't update drop: {e}") + raise EngineHTTPException(status_code=500, detail="Can't update drop") + + return data.DropUpdatedResponse( + dropper_claim_id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + active=drop.active, + ) + + +@app.get("/drops/{dropper_claim_id}/claimants", response_model=data.ClaimantsResponse) +async def get_claimants( + request: Request, + dropper_claim_id: UUID, + amount: Optional[int] = None, + added_by: Optional[str] = None, + address: Optional[str] = None, + limit: int = 10, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropListResponse: + """ + Get list of claimants for a given dropper contract. + """ + if address: + address = Web3.toChecksumAddress(address) + + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + results = actions.get_claimants( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + amount=amount, + added_by=added_by, + address=address, + limit=limit, + offset=offset, + ) + except Exception as e: + logger.info(f"Can't add claimants for claim {dropper_claim_id} with error: {e}") + raise EngineHTTPException(status_code=500, detail=f"Error adding claimants") + + return data.ClaimantsResponse(claimants=list(results)) + + +@app.post( + "/drops/{dropper_claim_id}/claimants/batch", response_model=data.ClaimantsResponse +) +async def add_claimants( + request: Request, + dropper_claim_id: UUID, + claimants_list: data.BatchAddClaimantsRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.ClaimantsResponse: + """ + Add addresses to particular claim + """ + + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + results = actions.add_claimants( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + claimants=claimants_list.claimants, + added_by=request.state.address, + ) + except actions.DublicateClaimantError: + raise EngineHTTPException( + status_code=400, + detail="Dublicated claimants in request please deduplicate them", + ) + except Exception as e: + logger.info(f"Can't add claimants for claim {dropper_claim_id} with error: {e}") + raise EngineHTTPException(status_code=500, detail=f"Error adding claimants") + + return data.ClaimantsResponse(claimants=results) + + +@app.delete( + "/drops/{dropper_claim_id}/claimants", response_model=data.RemoveClaimantsResponse +) +async def delete_claimants( + request: Request, + dropper_claim_id: UUID, + claimants_list: data.BatchRemoveClaimantsRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.RemoveClaimantsResponse: + + """ + Remove addresses to particular claim + """ + + try: + actions.ensure_admin_token_holder( + db_session, + dropper_claim_id, + request.state.address, + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + results = actions.delete_claimants( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + addresses=claimants_list.claimants, + ) + except Exception as e: + logger.info( + f"Can't remove claimants for claim {dropper_claim_id} with error: {e}" + ) + raise EngineHTTPException(status_code=500, detail=f"Error removing claimants") + + return data.RemoveClaimantsResponse(addresses=results) + + +@app.get("/drops/{dropper_claim_id}/claimants/search", response_model=data.Claimant) +async def get_claimant_in_drop( + request: Request, + dropper_claim_id: UUID, + address: str, + db_session: Session = Depends(db.yield_db_session), +) -> data.Claimant: + + """ + Return claimant from drop + """ + try: + actions.ensure_admin_token_holder( + db_session, + dropper_claim_id, + request.state.address, + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + claimant = actions.get_claimant( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + address=address, + ) + + except NoResultFound: + raise EngineHTTPException( + status_code=404, detail="Address not present in that drop." + ) + except Exception as e: + logger.error(f"Can't get claimant: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get claimant") + + return data.Claimant( + address=claimant.address, amount=claimant.amount, raw_amount=claimant.raw_amount + ) + + +@app.post("/drop/{dropper_claim_id}/refetch") +async def refetch_drop_signatures( + request: Request, + dropper_claim_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> Any: + """ + Refetch signatures for a drop + """ + + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + signatures = actions.refetch_drop_signatures( + db_session=db_session, dropper_claim_id=dropper_claim_id + ) + except Exception as e: + logger.info( + f"Can't refetch signatures for drop {dropper_claim_id} with error: {e}" + ) + raise EngineHTTPException( + status_code=500, detail=f"Error refetching signatures" + ) + + return signatures diff --git a/engineapi/engineapi/routes/dropper.py b/engineapi/engineapi/routes/dropper.py new file mode 100644 index 00000000..33b97e54 --- /dev/null +++ b/engineapi/engineapi/routes/dropper.py @@ -0,0 +1,906 @@ +""" +Lootbox API. +""" +import logging +from typing import List, Optional, Any, Dict +from uuid import UUID + + +from fastapi.middleware.cors import CORSMiddleware +from fastapi import FastAPI, Body, Request, Depends, Query +from hexbytes import HexBytes +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound +from web3 import Web3 + +from engineapi.models import DropperClaimant + +from .. import actions +from ..contracts import Dropper_interface +from .. import data +from .. import db +from .. import signatures +from ..middleware import EngineHTTPException, EngineAuthMiddleware +from ..settings import ( + ORIGINS, + DOCS_TARGET_PATH, + BLOCKCHAIN_WEB3_PROVIDERS, + UNSUPPORTED_BLOCKCHAIN_ERROR_MESSAGE, +) +from ..version import VERSION + + +logger = logging.getLogger(__name__) + + +tags_metadata = [{"name": "dropper", "description": "Moonstream Engine old drops API"}] + + +whitelist_paths: Dict[str, str] = {} +whitelist_paths.update( + { + "/drops": "GET", + "/drops/batch": "GET", + "/drops/claims": "GET", + "/drops/contracts": "GET", + "/drops/docs": "GET", + "/drops/terminus": "GET", + "/drops/blockchains": "GET", + "/drops/terminus/claims": "GET", + "/drops/openapi.json": "GET", + } +) + +app = FastAPI( + title=f"Moonstream Engine old drops API", + description="Moonstream Engine old drops API endpoints.", + version=VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + + +app.add_middleware(EngineAuthMiddleware, whitelist=whitelist_paths) + +app.add_middleware( + CORSMiddleware, + allow_origins=ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# TODO(zomglings): Take blockchain as a parameter (perhaps optional) here. Browser-based workflow is that +# user would already have selected their blockchain when connecting Metamask. +@app.get("", response_model=data.DropResponse) +@app.get("/", response_model=data.DropResponse) +async def get_drop_handler( + dropper_claim_id: UUID, + address: str, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropResponse: + """ + Get signed transaction for user with the given address. + """ + + address = Web3.toChecksumAddress(address) + + try: + claimant = actions.get_claimant(db_session, dropper_claim_id, address) + except NoResultFound: + raise EngineHTTPException( + status_code=403, detail="You are not authorized to claim that reward" + ) + except Exception as e: + raise EngineHTTPException(status_code=500, detail="Can't get claimant") + + try: + claimant_db_object = ( + db_session.query(DropperClaimant) + .filter(DropperClaimant.id == claimant.dropper_claimant_id) + .one() + ) + except Exception as err: + logger.error( + f"Can't get claimant object for drop: {dropper_claim_id} and address: {address}" + ) + raise EngineHTTPException(status_code=500, detail="Can't get claimant object.") + + if not claimant.active: + raise EngineHTTPException( + status_code=403, detail="Cannot claim rewards for an inactive claim" + ) + + # If block deadline has already been exceeded - the contract (or frontend) will handle it. + if claimant.claim_block_deadline is None: + raise EngineHTTPException( + status_code=403, + detail="Cannot claim rewards for a claim with no block deadline", + ) + + transformed_amount = claimant.raw_amount + if transformed_amount is None: + transformed_amount = actions.transform_claim_amount( + db_session, dropper_claim_id, claimant.amount + ) + + signature = claimant.signature + if signature is None or not claimant.is_recent_signature: + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[claimant.blockchain], + claimant.dropper_contract_address, + ) + message_hash_raw = dropper_contract.claimMessageHash( + claimant.claim_id, + claimant.address, + claimant.claim_block_deadline, + int(transformed_amount), + ).call() + + message_hash = HexBytes(message_hash_raw).hex() + + try: + signature = signatures.DROP_SIGNER.sign_message(message_hash) + claimant_db_object.signature = signature + db_session.commit() + except signatures.AWSDescribeInstancesFail: + raise EngineHTTPException(status_code=500) + except signatures.SignWithInstanceFail: + raise EngineHTTPException(status_code=500) + except Exception as err: + logger.error(f"Unexpected error in signing message process: {err}") + raise EngineHTTPException(status_code=500) + + return data.DropResponse( + claimant=claimant.address, + amount=str(transformed_amount), + claim_id=claimant.claim_id, + block_deadline=claimant.claim_block_deadline, + signature=signature, + title=claimant.title, + description=claimant.description, + ) + + +@app.get("/batch", response_model=List[data.DropBatchResponseItem]) +async def get_drop_batch_handler( + blockchain: str, + address: str, + limit: int = 10, + offset: int = 0, + current_block_number: Optional[int] = Query(None), + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropBatchResponseItem]: + """ + Get signed transaction for all user drops. + """ + if blockchain not in BLOCKCHAIN_WEB3_PROVIDERS: + raise EngineHTTPException( + status_code=404, detail=UNSUPPORTED_BLOCKCHAIN_ERROR_MESSAGE + ) + + address = Web3.toChecksumAddress(address) + + try: + claimant_drops = actions.get_claimant_drops( + db_session, blockchain, address, current_block_number, limit, offset + ) + except NoResultFound: + raise EngineHTTPException( + status_code=403, detail="You are not authorized to claim that reward" + ) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500, detail="Can't get claimant") + + # get claimants + try: + claimants = ( + db_session.query(DropperClaimant) + .filter( + DropperClaimant.id.in_( + [item.dropper_claimant_id for item in claimant_drops] + ) + ) + .all() + ) + except Exception as err: + logger.error(f"Can't get claimant objects for address: {address}") + raise EngineHTTPException(status_code=500, detail="Can't get claimant objects.") + + claimants_dict = {item.id: item for item in claimants} + + # generate list of claims + + claims: List[data.DropBatchResponseItem] = [] + + commit_required = False + + for claimant_drop in claimant_drops: + + transformed_amount = claimant_drop.raw_amount + + if transformed_amount is None: + + transformed_amount = actions.transform_claim_amount( + db_session, claimant_drop.dropper_claim_id, claimant_drop.amount + ) + + signature = claimant_drop.signature + if signature is None or not claimant_drop.is_recent_signature: + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[blockchain], + claimant_drop.dropper_contract_address, + ) + + message_hash_raw = dropper_contract.claimMessageHash( + claimant_drop.claim_id, + claimant_drop.address, + claimant_drop.claim_block_deadline, + int(transformed_amount), + ).call() + + message_hash = HexBytes(message_hash_raw).hex() + + try: + signature = signatures.DROP_SIGNER.sign_message(message_hash) + claimants_dict[claimant_drop.dropper_claimant_id].signature = signature + commit_required = True + except signatures.AWSDescribeInstancesFail: + raise EngineHTTPException(status_code=500) + except signatures.SignWithInstanceFail: + raise EngineHTTPException(status_code=500) + except Exception as err: + logger.error(f"Unexpected error in signing message process: {err}") + raise EngineHTTPException(status_code=500) + + claims.append( + data.DropBatchResponseItem( + claimant=claimant_drop.address, + amount=int(transformed_amount), + amount_string=str(transformed_amount), + claim_id=claimant_drop.claim_id, + block_deadline=claimant_drop.claim_block_deadline, + signature=signature, + dropper_claim_id=claimant_drop.dropper_claim_id, + dropper_contract_address=claimant_drop.dropper_contract_address, + blockchain=claimant_drop.blockchain, + active=claimant_drop.active, + title=claimant_drop.title, + description=claimant_drop.description, + ) + ) + + if commit_required: + db_session.commit() + + return claims + + +@app.get("/blockchains") +async def get_drops_blockchains_handler( + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropperBlockchainResponse]: + """ + Get list of blockchains. + """ + + try: + results = actions.list_drops_blockchains(db_session=db_session) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get list of drops end with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get drops") + + response = [ + data.DropperBlockchainResponse( + blockchain=result.blockchain, + ) + for result in results + ] + + return response + + +@app.get("/contracts", response_model=List[data.DropperContractResponse]) +async def get_dropper_contracts_handler( + blockchain: Optional[str] = Query(None), + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropperContractResponse]: + """ + Get list of drops for a given dropper contract. + """ + + try: + results = actions.list_dropper_contracts( + db_session=db_session, blockchain=blockchain + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get list of dropper contracts end with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get contracts") + + response = [ + data.DropperContractResponse( + id=result.id, + blockchain=result.blockchain, + address=result.address, + title=result.title, + description=result.description, + image_uri=result.image_uri, + ) + for result in results + ] + + return response + + +@app.get("/terminus") +async def get_drops_terminus_handler( + blockchain: str = Query(None), + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropperTerminusResponse]: + + """ + Return distinct terminus pools + """ + + try: + results = actions.list_drops_terminus( + db_session=db_session, blockchain=blockchain + ) + except Exception as e: + logger.error(f"Can't get list of terminus contracts end with error: {e}") + raise EngineHTTPException( + status_code=500, detail="Can't get terminus contracts" + ) + + response = [ + data.DropperTerminusResponse( + terminus_address=result.terminus_address, + terminus_pool_id=result.terminus_pool_id, + blockchain=result.blockchain, + ) + for result in results + ] + + return response + + +@app.get("/claims", response_model=data.DropListResponse) +async def get_drop_list_handler( + blockchain: str, + claimant_address: str, + dropper_contract_address: Optional[str] = Query(None), + terminus_address: Optional[str] = Query(None), + terminus_pool_id: Optional[int] = Query(None), + active: Optional[bool] = Query(None), + limit: int = 20, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropListResponse: + """ + Get list of drops for a given dropper contract and claimant address. + """ + + if dropper_contract_address: + dropper_contract_address = Web3.toChecksumAddress(dropper_contract_address) + + if claimant_address: + claimant_address = Web3.toChecksumAddress(claimant_address) + + if terminus_address: + terminus_address = Web3.toChecksumAddress(terminus_address) + + try: + results = actions.get_claims( + db_session=db_session, + dropper_contract_address=dropper_contract_address, + blockchain=blockchain, + claimant_address=claimant_address, + terminus_address=terminus_address, + terminus_pool_id=terminus_pool_id, + active=active, + limit=limit, + offset=offset, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error( + f"Can't get claims for user {claimant_address} end with error: {e}" + ) + raise EngineHTTPException(status_code=500, detail="Can't get claims") + + return data.DropListResponse(drops=[result for result in results]) + + +@app.get("/claims/{dropper_claim_id}", response_model=data.DropperClaimResponse) +async def get_drop_handler( + request: Request, + dropper_claim_id: str, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropperClaimResponse: + """ + Get list of drops for a given dropper contract and claimant address. + """ + + try: + drop = actions.get_drop( + db_session=db_session, dropper_claim_id=dropper_claim_id + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get drop {dropper_claim_id} end with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get drop") + + if drop.terminus_address is not None and drop.terminus_pool_id is not None: + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + return data.DropperClaimResponse( + id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + active=drop.active, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + ) + + +@app.get("/terminus/claims", response_model=data.DropListResponse) +async def get_drop_terminus_list_handler( + blockchain: str, + terminus_address: str, + terminus_pool_id: int, + dropper_contract_address: Optional[str] = Query(None), + active: Optional[bool] = Query(None), + limit: int = 20, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropListResponse: + """ + Get list of drops for a given terminus address. + """ + + if dropper_contract_address: + dropper_contract_address = Web3.toChecksumAddress(dropper_contract_address) + + terminus_address = Web3.toChecksumAddress(terminus_address) + + try: + results = actions.get_terminus_claims( + db_session=db_session, + dropper_contract_address=dropper_contract_address, + blockchain=blockchain, + terminus_address=terminus_address, + terminus_pool_id=terminus_pool_id, + active=active, + limit=limit, + offset=offset, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error( + f"Can't get Terminus claims (blockchain={blockchain}, address={terminus_address}, pool_id={terminus_pool_id}): {e}" + ) + raise EngineHTTPException(status_code=500, detail="Can't get claims") + + return data.DropListResponse(drops=[result for result in results]) + + +@app.post("/claims", response_model=data.DropCreatedResponse) +async def create_drop( + request: Request, + register_request: data.DropRegisterRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.DropCreatedResponse: + + """ + Create a drop for a given dropper contract. + """ + try: + actions.ensure_dropper_contract_owner( + db_session, register_request.dropper_contract_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Dropper contract not found") + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + if register_request.terminus_address: + register_request.terminus_address = Web3.toChecksumAddress( + register_request.terminus_address + ) + + try: + claim = actions.create_claim( + db_session=db_session, + dropper_contract_id=register_request.dropper_contract_id, + title=register_request.title, + description=register_request.description, + claim_block_deadline=register_request.claim_block_deadline, + terminus_address=register_request.terminus_address, + terminus_pool_id=register_request.terminus_pool_id, + claim_id=register_request.claim_id, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Dropper contract not found") + except Exception as e: + logger.error(f"Can't create claim: {e}") + raise EngineHTTPException(status_code=500, detail="Can't create claim") + + return data.DropCreatedResponse( + dropper_claim_id=claim.id, + dropper_contract_id=claim.dropper_contract_id, + title=claim.title, + description=claim.description, + claim_block_deadline=claim.claim_block_deadline, + terminus_address=claim.terminus_address, + terminus_pool_id=claim.terminus_pool_id, + claim_id=claim.claim_id, + ) + + +@app.put( + "/claims/{dropper_claim_id}/activate", + response_model=data.DropUpdatedResponse, +) +async def activate_drop( + request: Request, + dropper_claim_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropUpdatedResponse: + + """ + Activate a given drop by drop id. + """ + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + try: + drop = actions.activate_drop( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + except Exception as e: + logger.error(f"Can't activate drop: {e}") + raise EngineHTTPException(status_code=500, detail="Can't activate drop") + + return data.DropUpdatedResponse( + dropper_claim_id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + active=drop.active, + ) + + +@app.put( + "/claims/{dropper_claim_id}/deactivate", + response_model=data.DropUpdatedResponse, +) +async def deactivate_drop( + request: Request, + dropper_claim_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropUpdatedResponse: + + """ + Activate a given drop by drop id. + """ + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + try: + drop = actions.deactivate_drop( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + except Exception as e: + logger.error(f"Can't activate drop: {e}") + raise EngineHTTPException(status_code=500, detail="Can't activate drop") + + return data.DropUpdatedResponse( + dropper_claim_id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + active=drop.active, + ) + + +@app.put("/claims/{dropper_claim_id}", response_model=data.DropUpdatedResponse) +async def update_drop( + request: Request, + dropper_claim_id: UUID, + update_request: data.DropUpdateRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.DropUpdatedResponse: + + """ + Update a given drop by drop id. + """ + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + + try: + drop = actions.update_drop( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + title=update_request.title, + description=update_request.description, + claim_block_deadline=update_request.claim_block_deadline, + terminus_address=update_request.terminus_address, + terminus_pool_id=update_request.terminus_pool_id, + claim_id=update_request.claim_id, + address=request.state.address, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="Drop not found") + except Exception as e: + logger.error(f"Can't update drop: {e}") + raise EngineHTTPException(status_code=500, detail="Can't update drop") + + return data.DropUpdatedResponse( + dropper_claim_id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + active=drop.active, + ) + + +@app.get("/claimants", response_model=data.DropListResponse) +async def get_claimants( + request: Request, + dropper_claim_id: UUID, + limit: int = 10, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropListResponse: + """ + Get list of claimants for a given dropper contract. + """ + + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + results = actions.get_claimants( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + limit=limit, + offset=offset, + ) + except Exception as e: + logger.info(f"Can't add claimants for claim {dropper_claim_id} with error: {e}") + raise EngineHTTPException(status_code=500, detail=f"Error adding claimants") + + return data.DropListResponse(drops=list(results)) + + +@app.post("/claimants", response_model=data.ClaimantsResponse) +async def add_claimants( + request: Request, + add_claimants_request: data.DropAddClaimantsRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.ClaimantsResponse: + """ + Add addresses to particular claim + """ + + try: + actions.ensure_admin_token_holder( + db_session, add_claimants_request.dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + results = actions.add_claimants( + db_session=db_session, + dropper_claim_id=add_claimants_request.dropper_claim_id, + claimants=add_claimants_request.claimants, + added_by=request.state.address, + ) + + except actions.DublicateClaimantError: + raise EngineHTTPException( + status_code=400, + detail="Dublicated claimants in request please deduplicate them.", + ) + except Exception as e: + logger.info( + f"Can't add claimants for claim {add_claimants_request.dropper_claim_id} with error: {e}" + ) + raise EngineHTTPException(status_code=500, detail=f"Error adding claimants") + + return data.ClaimantsResponse(claimants=results) + + +@app.delete("/claimants", response_model=data.RemoveClaimantsResponse) +async def delete_claimants( + request: Request, + remove_claimants_request: data.DropRemoveClaimantsRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.RemoveClaimantsResponse: + + """ + Remove addresses to particular claim + """ + + try: + actions.ensure_admin_token_holder( + db_session, + remove_claimants_request.dropper_claim_id, + request.state.address, + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + results = actions.delete_claimants( + db_session=db_session, + dropper_claim_id=remove_claimants_request.dropper_claim_id, + addresses=remove_claimants_request.addresses, + ) + except Exception as e: + logger.info( + f"Can't remove claimants for claim {remove_claimants_request.dropper_claim_id} with error: {e}" + ) + raise EngineHTTPException(status_code=500, detail=f"Error removing claimants") + + return data.RemoveClaimantsResponse(addresses=results) + + +@app.get("/claimants/search", response_model=data.Claimant) +async def get_claimant_in_drop( + request: Request, + dropper_claim_id: UUID, + address: str, + db_session: Session = Depends(db.yield_db_session), +) -> data.Claimant: + + """ + Return claimant from drop + """ + try: + actions.ensure_admin_token_holder( + db_session, + dropper_claim_id, + request.state.address, + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + claimant = actions.get_claimant( + db_session=db_session, + dropper_claim_id=dropper_claim_id, + address=address, + ) + + except NoResultFound: + raise EngineHTTPException( + status_code=404, detail="Address not present in that drop." + ) + except Exception as e: + logger.error(f"Can't get claimant: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get claimant") + + return data.Claimant(address=claimant.address, amount=claimant.amount) + + +@app.post("/drop/{dropper_claim_id}/refetch") +async def refetch_drop_signatures( + request: Request, + dropper_claim_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> Any: + """ + Refetch signatures for a drop + """ + + try: + actions.ensure_admin_token_holder( + db_session, dropper_claim_id, request.state.address + ) + except actions.AuthorizationError as e: + logger.error(e) + raise EngineHTTPException(status_code=403) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500) + + try: + signatures = actions.refetch_drop_signatures( + db_session=db_session, dropper_claim_id=dropper_claim_id + ) + except Exception as e: + logger.info( + f"Can't refetch signatures for drop {dropper_claim_id} with error: {e}" + ) + raise EngineHTTPException( + status_code=500, detail=f"Error refetching signatures" + ) + + return signatures diff --git a/engineapi/engineapi/routes/leaderboard.py b/engineapi/engineapi/routes/leaderboard.py new file mode 100644 index 00000000..c0f9529f --- /dev/null +++ b/engineapi/engineapi/routes/leaderboard.py @@ -0,0 +1,336 @@ +""" +Leaderboard API. +""" +import logging +from uuid import UUID + +from web3 import Web3 +from fastapi import FastAPI, Request, Depends, Response +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound +from typing import List, Optional + +from .. import actions +from .. import data +from .. import db +from ..middleware import ExtractBearerTokenMiddleware, EngineHTTPException +from ..settings import DOCS_TARGET_PATH, bugout_client as bc +from ..version import VERSION + +logger = logging.getLogger(__name__) + + +tags_metadata = [ + {"name": "leaderboard", "description": "Moonstream Engine leaderboard API"} +] + + +leaderboad_whitelist = { + "/leaderboard/quartiles": "GET", + "/leaderboard/count/addresses": "GET", + "/leaderboard/position": "GET", + "/leaderboard": "GET", + "/leaderboard/rank": "GET", + "/leaderboard/ranks": "GET", +} + +app = FastAPI( + title=f"Moonstream Engine leaderboard API", + description="Moonstream Engine leaderboard API endpoints.", + version=VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + + +app.add_middleware(ExtractBearerTokenMiddleware, whitelist=leaderboad_whitelist) + +app.add_middleware( + CORSMiddleware, + allow_origins="*", + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/count/addresses") +async def count_addresses( + leaderboard_id: UUID, + db_session: Session = Depends(db.yield_db_session), +): + + """ + Returns the number of addresses in the leaderboard. + """ + + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + count = actions.get_leaderboard_total_count(db_session, leaderboard_id) + + return data.CountAddressesResponse(count=count) + + +@app.get("/quartiles") +async def quartiles( + leaderboard_id: UUID, + db_session: Session = Depends(db.yield_db_session), +): + + """ + Returns the quartiles of the leaderboard. + """ + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + try: + q1, q2, q3 = actions.get_qurtiles(db_session, leaderboard_id) + + except actions.LeaderboardIsEmpty: + return Response(status_code=204) + except Exception as e: + logger.error(f"Error while getting quartiles: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.QuartilesResponse( + percentile_25={"address": q1[0], "score": q1[1], "rank": q1[2]}, + percentile_50={"address": q2[0], "score": q2[1], "rank": q2[2]}, + percentile_75={"address": q3[0], "score": q3[1], "rank": q3[2]}, + ) + + +@app.get("/position") +async def position( + leaderboard_id: UUID, + address: str, + window_size: int = 1, + limit: int = 10, + offset: int = 0, + normalize_addresses: bool = True, + db_session: Session = Depends(db.yield_db_session), +): + + """ + Returns the leaderboard posotion for the given address. + With given window size. + """ + + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + if normalize_addresses: + address = Web3.toChecksumAddress(address) + + positions = actions.get_position( + db_session, leaderboard_id, address, window_size, limit, offset + ) + + return positions + + +@app.get("") +@app.get("/") +async def leaderboard( + leaderboard_id: UUID, + limit: int = 10, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> List[data.LeaderboardPosition]: + + """ + Returns the leaderboard positions. + """ + + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + leaderboard_positions = actions.get_leaderboard_positions( + db_session, leaderboard_id, limit, offset + ) + result = [ + data.LeaderboardPosition( + address=position.address, + score=position.score, + rank=position.rank, + points_data=position.points_data, + ) + for position in leaderboard_positions + ] + + return result + + +@app.get("/rank") +async def rank( + leaderboard_id: UUID, + rank: int = 1, + limit: Optional[int] = None, + offset: Optional[int] = None, + db_session: Session = Depends(db.yield_db_session), +) -> List[data.LeaderboardPosition]: + + """ + Returns the leaderboard scores for the given rank. + """ + + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + leaderboard_rank = actions.get_rank( + db_session, leaderboard_id, rank, limit=limit, offset=offset + ) + results = [ + data.LeaderboardPosition( + address=rank_position.address, + score=rank_position.score, + rank=rank_position.rank, + points_data=rank_position.points_data, + ) + for rank_position in leaderboard_rank + ] + return results + + +@app.get("/ranks") +async def ranks( + leaderboard_id: UUID, db_session: Session = Depends(db.yield_db_session) +) -> List[data.RanksResponse]: + + """ + Returns the leaderboard rank buckets overview with score and size of bucket. + """ + + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + ranks = actions.get_ranks(db_session, leaderboard_id) + results = [ + data.RanksResponse( + score=rank.score, + rank=rank.rank, + size=rank.size, + ) + for rank in ranks + ] + return results + + +@app.put("/{leaderboard_id}/scores") +async def leaderboard( + request: Request, + leaderboard_id: UUID, + scores: List[data.Score], + overwrite: bool = False, + normalize_addresses: bool = True, + db_session: Session = Depends(db.yield_db_session), +): + + """ + Put the leaderboard to the database. + """ + + access = actions.check_leaderboard_resource_permissions( + db_session=db_session, + leaderboard_id=leaderboard_id, + token=request.state.token, + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard." + ) + + ### Check if leaderboard exists + try: + actions.get_leaderboard_by_id(db_session, leaderboard_id) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + try: + leaderboard_points = actions.add_scores( + db_session=db_session, + leaderboard_id=leaderboard_id, + scores=scores, + overwrite=overwrite, + normalize_addresses=normalize_addresses, + ) + except actions.DuplicateLeaderboardAddressError as e: + raise EngineHTTPException( + status_code=409, + detail=f"Duplicates in push to database is disallowed.\n List of duplicates:{e.duplicates}.\n Please handle duplicates manualy.", + ) + except actions.LeaderboardDeleteScoresError as e: + logger.error(f"Delete scores failed with error: {e}") + raise EngineHTTPException( + status_code=500, + detail=f"Delete scores failed.", + ) + except Exception as e: + logger.error(f"Score update failed with error: {e}") + raise EngineHTTPException(status_code=500, detail="Score update failed.") + + return leaderboard_points diff --git a/engineapi/engineapi/routes/metatx.py b/engineapi/engineapi/routes/metatx.py new file mode 100644 index 00000000..3b0938ec --- /dev/null +++ b/engineapi/engineapi/routes/metatx.py @@ -0,0 +1,337 @@ +""" +Contract registration API + +Moonstream users can register contracts on Moonstream Engine. This allows them to use these contracts +as part of their chain-adjacent activities (like performing signature-based token distributions on the +Dropper contract). +""" +import logging +from typing import Dict, List, Optional +from uuid import UUID + +from fastapi import Body, Depends, FastAPI, Query, Request, Path +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session + +from .. import contracts_actions, data, db +from ..middleware import BroodAuthMiddleware, EngineHTTPException +from ..settings import DOCS_TARGET_PATH, ORIGINS +from ..version import VERSION + +logger = logging.getLogger(__name__) + + +TITLE = "Moonstream Engine Contracts API" +DESCRIPTION = "Users can register contracts on the Moonstream Engine for use in chain-adjacent activities, like setting up signature-based token distributions." + + +tags_metadata = [ + { + "name": "contracts", + "description": DESCRIPTION, + }, + {"name": "requests", "description": "Call requests for registered contracts."}, +] + + +whitelist_paths = { + "/metatx/openapi.json": "GET", + f"/metatx/{DOCS_TARGET_PATH}": "GET", + "/metatx/contracts/types": "GET", + "/metatx/requests": "GET", +} + +app = FastAPI( + title=TITLE, + description=DESCRIPTION, + version=VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + + +app.add_middleware(BroodAuthMiddleware, whitelist=whitelist_paths) + +app.add_middleware( + CORSMiddleware, + allow_origins=ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/contracts/types", tags=["contracts"]) +async def contract_types() -> Dict[str, str]: + """ + Describes the contract_types that users can register contracts as against this API. + """ + return { + data.ContractType.raw.value: "A generic smart contract. You can ask users to submit arbitrary calldata to this contract.", + data.ContractType.dropper.value: "A Dropper contract. You can authorize users to submit claims against this contract.", + } + + +@app.get("/contracts", tags=["contracts"], response_model=List[data.RegisteredContract]) +async def list_registered_contracts( + request: Request, + blockchain: Optional[str] = Query(None), + address: Optional[str] = Query(None), + contract_type: Optional[data.ContractType] = Query(None), + limit: int = Query(10), + offset: Optional[int] = Query(None), + db_session: Session = Depends(db.yield_db_read_only_session), +) -> List[data.RegisteredContract]: + """ + Users can use this endpoint to look up the contracts they have registered against this API. + """ + try: + contracts = contracts_actions.lookup_registered_contracts( + db_session=db_session, + moonstream_user_id=request.state.user.id, + blockchain=blockchain, + address=address, + contract_type=contract_type, + limit=limit, + offset=offset, + ) + except Exception as err: + logger.error(repr(err)) + raise EngineHTTPException(status_code=500) + return [contract for contract in contracts] + + +@app.get( + "/contracts/{contract_id}", + tags=["contracts"], + response_model=data.RegisteredContract, +) +async def get_registered_contract( + request: Request, + contract_id: UUID = Path(...), + db_session: Session = Depends(db.yield_db_read_only_session), +) -> List[data.RegisteredContract]: + """ + Get the contract by ID. + """ + try: + contract = contracts_actions.get_registered_contract( + db_session=db_session, + moonstream_user_id=request.state.user.id, + contract_id=contract_id, + ) + except NoResultFound: + raise EngineHTTPException( + status_code=404, + detail="Either there is not contract with that ID or you do not have access to that contract.", + ) + except Exception as err: + logger.error(repr(err)) + raise EngineHTTPException(status_code=500) + return contract + + +@app.post("/contracts", tags=["contracts"], response_model=data.RegisteredContract) +async def register_contract( + request: Request, + contract: data.RegisterContractRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.RegisteredContract: + """ + Allows users to register contracts. + """ + try: + registered_contract = contracts_actions.register_contract( + db_session=db_session, + moonstream_user_id=request.state.user.id, + blockchain=contract.blockchain, + address=contract.address, + contract_type=contract.contract_type, + title=contract.title, + description=contract.description, + image_uri=contract.image_uri, + ) + except contracts_actions.ContractAlreadyRegistered: + raise EngineHTTPException( + status_code=409, + detail="Contract already registered", + ) + return registered_contract + + +@app.put( + "/contracts/{contract_id}", + tags=["contracts"], + response_model=data.RegisteredContract, +) +async def update_contract( + request: Request, + contract_id: UUID = Path(...), + update_info: data.UpdateContractRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> data.RegisteredContract: + try: + contract = contracts_actions.update_registered_contract( + db_session, + request.state.user.id, + contract_id, + update_info.title, + update_info.description, + update_info.image_uri, + update_info.ignore_nulls, + ) + except NoResultFound: + raise EngineHTTPException( + status_code=404, + detail="Either there is not contract with that ID or you do not have access to that contract.", + ) + except Exception as err: + logger.error(repr(err)) + raise EngineHTTPException(status_code=500) + + return contract + + +@app.delete( + "/contracts/{contract_id}", + tags=["contracts"], + response_model=data.RegisteredContract, +) +async def delete_contract( + request: Request, + contract_id: UUID, + db_session: Session = Depends(db.yield_db_session), +) -> data.RegisteredContract: + """ + Allows users to delete contracts that they have registered. + """ + try: + deleted_contract = contracts_actions.delete_registered_contract( + db_session=db_session, + moonstream_user_id=request.state.user.id, + registered_contract_id=contract_id, + ) + except Exception as err: + logger.error(repr(err)) + raise EngineHTTPException(status_code=500) + + return deleted_contract + + +@app.get("/requests", tags=["requests"], response_model=List[data.CallRequest]) +async def list_requests( + contract_id: Optional[UUID] = Query(None), + contract_address: Optional[str] = Query(None), + caller: str = Query(...), + limit: int = Query(100), + offset: Optional[int] = Query(None), + show_expired: Optional[bool] = Query(False), + db_session: Session = Depends(db.yield_db_read_only_session), +) -> List[data.CallRequest]: + """ + Allows API user to see all unexpired call requests for a given caller against a given contract. + + At least one of `contract_id` or `contract_address` must be provided as query parameters. + """ + try: + requests = contracts_actions.list_call_requests( + db_session=db_session, + contract_id=contract_id, + contract_address=contract_address, + caller=caller, + limit=limit, + offset=offset, + show_expired=show_expired, + ) + except ValueError as e: + logger.error(repr(e)) + raise EngineHTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(repr(e)) + raise EngineHTTPException(status_code=500) + + return requests + + +@app.get("/requests/{request_id}", tags=["requests"], response_model=data.CallRequest) +async def get_request( + request_id: UUID = Path(...), + db_session: Session = Depends(db.yield_db_read_only_session), +) -> List[data.CallRequest]: + """ + Allows API user to see call request. + + At least one of `contract_id` or `contract_address` must be provided as query parameters. + """ + try: + result = contracts_actions.get_call_requests( + db_session=db_session, + request_id=request_id, + ) + except contracts_actions.CallRequestNotFound: + raise EngineHTTPException( + status_code=404, + detail="There is no call request with that ID.", + ) + except Exception as e: + logger.error(repr(e)) + raise EngineHTTPException(status_code=500) + + return result + + +@app.post("/requests", tags=["requests"], response_model=int) +async def create_requests( + request: Request, + data: data.CreateCallRequestsAPIRequest = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> int: + """ + Allows API user to register call requests from given contract details, TTL, and call specifications. + + At least one of `contract_id` or `contract_address` must be provided in the request body. + """ + try: + num_requests = contracts_actions.request_calls( + db_session=db_session, + moonstream_user_id=request.state.user.id, + registered_contract_id=data.contract_id, + contract_address=data.contract_address, + call_specs=data.specifications, + ttl_days=data.ttl_days, + ) + except contracts_actions.InvalidAddressFormat as err: + raise EngineHTTPException( + status_code=400, + detail=f"Address not passed web3checksum validation, err: {err}", + ) + except Exception as err: + logger.error(repr(err)) + raise EngineHTTPException(status_code=500) + + return num_requests + + +@app.delete("/requests", tags=["requests"], response_model=int) +async def delete_requests( + request: Request, + request_ids: List[UUID] = Body(...), + db_session: Session = Depends(db.yield_db_session), +) -> int: + """ + Allows users to delete requests. + """ + try: + deleted_requests = contracts_actions.delete_requests( + db_session=db_session, + moonstream_user_id=request.state.user.id, + request_ids=request_ids, + ) + except Exception as err: + logger.error(repr(err)) + raise EngineHTTPException(status_code=500) + + return deleted_requests diff --git a/engineapi/engineapi/routes/play.py b/engineapi/engineapi/routes/play.py new file mode 100644 index 00000000..20c36128 --- /dev/null +++ b/engineapi/engineapi/routes/play.py @@ -0,0 +1,421 @@ +""" +Moonstream Engine Play API. +""" +import logging +from typing import List, Optional +from uuid import UUID + +from fastapi import Request, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound +from hexbytes import HexBytes +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from web3 import Web3 + +from ..models import DropperClaimant +from .. import actions +from .. import data +from .. import db +from .. import signatures +from ..contracts import Dropper_interface +from ..middleware import EngineHTTPException +from ..settings import BLOCKCHAIN_WEB3_PROVIDERS, DOCS_TARGET_PATH +from ..version import VERSION + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +tags_metadata = [{"name": "Play", "description": "Moonstream Engine Play API"}] + + +app = FastAPI( + title=f"Moonstream Engine Play API", + description="Moonstream Engine Play API endpoints.", + version=VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + + +app.add_middleware( + CORSMiddleware, + allow_origins="*", + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/blockchains") +async def get_drops_blockchains_handler( + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropperBlockchainResponse]: + """ + Get list of blockchains. + """ + + try: + results = actions.list_drops_blockchains(db_session=db_session) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get list of drops end with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get drops") + + response = [ + data.DropperBlockchainResponse( + blockchain=result.blockchain, + ) + for result in results + ] + + return response + + +@app.get("/claims/batch", response_model=List[data.DropBatchResponseItem]) +async def get_drop_batch_handler( + blockchain: str, + address: str, + limit: int = 10, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropBatchResponseItem]: + """ + Get signed transaction for all user drops. + """ + + address = Web3.toChecksumAddress(address) + + try: + claimant_drops = actions.get_claimant_drops( + db_session, blockchain, address, limit, offset + ) + except NoResultFound: + raise EngineHTTPException( + status_code=403, detail="You are not authorized to claim that reward" + ) + except Exception as e: + logger.error(e) + raise EngineHTTPException(status_code=500, detail="Can't get claimant") + + # get claimants + try: + claimants = ( + db_session.query(DropperClaimant) + .filter( + DropperClaimant.id.in_( + [item.dropper_claimant_id for item in claimant_drops] + ) + ) + .all() + ) + except Exception as err: + logger.error(f"Can't get claimant objects for address: {address}") + raise EngineHTTPException(status_code=500, detail="Can't get claimant objects.") + + claimants_dict = {item.id: item for item in claimants} + + # generate list of claims + + claims: List[data.DropBatchResponseItem] = [] + + commit_required = False + + for claimant_drop in claimant_drops: + + transformed_amount = claimant_drop.raw_amount + + if transformed_amount is None: + + transformed_amount = actions.transform_claim_amount( + db_session, claimant_drop.dropper_claim_id, claimant_drop.amount + ) + + signature = claimant_drop.signature + if signature is None or not claimant_drop.is_recent_signature: + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[blockchain], + claimant_drop.dropper_contract_address, + ) + + message_hash_raw = dropper_contract.claimMessageHash( + claimant_drop.claim_id, + claimant_drop.address, + claimant_drop.claim_block_deadline, + int(transformed_amount), + ).call() + + message_hash = HexBytes(message_hash_raw).hex() + + try: + signature = signatures.DROP_SIGNER.sign_message(message_hash) + claimants_dict[claimant_drop.dropper_claimant_id].signature = signature + commit_required = True + except signatures.AWSDescribeInstancesFail: + raise EngineHTTPException(status_code=500) + except signatures.SignWithInstanceFail: + raise EngineHTTPException(status_code=500) + except Exception as err: + logger.error(f"Unexpected error in signing message process: {err}") + raise EngineHTTPException(status_code=500) + + claims.append( + data.DropBatchResponseItem( + claimant=claimant_drop.address, + amount=transformed_amount, + amount_string=str(transformed_amount), + claim_id=claimant_drop.claim_id, + block_deadline=claimant_drop.claim_block_deadline, + signature=signature, + dropper_claim_id=claimant_drop.dropper_claim_id, + dropper_contract_address=claimant_drop.dropper_contract_address, + blockchain=claimant_drop.blockchain, + active=claimant_drop.active, + title=claimant_drop.title, + description=claimant_drop.description, + ) + ) + + if commit_required: + db_session.commit() + + return claims + + +@app.get("/claims/{dropper_claim_id}", response_model=data.DropResponse) +async def get_drop_handler( + dropper_claim_id: UUID, + address: str, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropResponse: + """ + Get signed transaction for user with the given address for that claim. + """ + + address = Web3.toChecksumAddress(address) + + try: + claimant = actions.get_claimant(db_session, dropper_claim_id, address) + except NoResultFound: + raise EngineHTTPException( + status_code=403, detail="You are not authorized to claim that reward" + ) + except Exception as e: + raise EngineHTTPException(status_code=500, detail="Can't get claimant") + + try: + claimant_db_object = ( + db_session.query(DropperClaimant) + .filter(DropperClaimant.id == claimant.dropper_claimant_id) + .one() + ) + except Exception as err: + logger.error( + f"Can't get claimant object for drop: {dropper_claim_id} and address: {address}" + ) + raise EngineHTTPException(status_code=500, detail="Can't get claimant object.") + + if not claimant.active: + raise EngineHTTPException( + status_code=403, detail="Cannot claim rewards for an inactive claim" + ) + + # If block deadline has already been exceeded - the contract (or frontend) will handle it. + if claimant.claim_block_deadline is None: + raise EngineHTTPException( + status_code=403, + detail="Cannot claim rewards for a claim with no block deadline", + ) + + transformed_amount = claimant.raw_amount + if transformed_amount is None: + transformed_amount = actions.transform_claim_amount( + db_session, dropper_claim_id, claimant.amount + ) + + signature = claimant.signature + if signature is None or not claimant.is_recent_signature: + dropper_contract = Dropper_interface.Contract( + claimant.blockchain, claimant.dropper_contract_address + ) + message_hash_raw = dropper_contract.claimMessageHash( + claimant.claim_id, + claimant.address, + claimant.claim_block_deadline, + int(transformed_amount), + ).call() + + message_hash = HexBytes(message_hash_raw).hex() + + try: + signature = signatures.DROP_SIGNER.sign_message(message_hash) + claimant_db_object.signature = signature + db_session.commit() + except signatures.AWSDescribeInstancesFail: + raise EngineHTTPException(status_code=500) + except signatures.SignWithInstanceFail: + raise EngineHTTPException(status_code=500) + except Exception as err: + logger.error(f"Unexpected error in signing message process: {err}") + raise EngineHTTPException(status_code=500) + + return data.DropResponse( + claimant=claimant.address, + amount=str(transformed_amount), + claim_id=claimant.claim_id, + block_deadline=claimant.claim_block_deadline, + signature=signature, + title=claimant.title, + description=claimant.description, + ) + + +@app.get("/drops", response_model=data.DropListResponse) +async def get_drop_list_handler( + blockchain: str, + claimant_address: str, + dropper_contract_address: Optional[str] = Query(None), + terminus_address: Optional[str] = Query(None), + terminus_pool_id: Optional[int] = Query(None), + active: Optional[bool] = Query(None), + limit: int = 20, + offset: int = 0, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropListResponse: + """ + Get list of drops for a given dropper contract and claimant address. + """ + + if dropper_contract_address: + dropper_contract_address = Web3.toChecksumAddress(dropper_contract_address) + + if claimant_address: + claimant_address = Web3.toChecksumAddress(claimant_address) + + if terminus_address: + terminus_address = Web3.toChecksumAddress(terminus_address) + + try: + results = actions.get_claims( + db_session=db_session, + dropper_contract_address=dropper_contract_address, + blockchain=blockchain, + claimant_address=claimant_address, + terminus_address=terminus_address, + terminus_pool_id=terminus_pool_id, + active=active, + limit=limit, + offset=offset, + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error( + f"Can't get claims for user {claimant_address} end with error: {e}" + ) + raise EngineHTTPException(status_code=500, detail="Can't get claims") + + return data.DropListResponse(drops=[result for result in results]) + + +@app.get("/drops/contracts", response_model=List[data.DropperContractResponse]) +async def get_dropper_contracts_handler( + blockchain: Optional[str] = Query(None), + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropperContractResponse]: + """ + Get list of drops for a given dropper contract. + """ + + try: + results = actions.list_dropper_contracts( + db_session=db_session, blockchain=blockchain + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get list of dropper contracts end with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get contracts") + + response = [ + data.DropperContractResponse( + id=result.id, + blockchain=result.blockchain, + address=result.address, + title=result.title, + description=result.description, + image_uri=result.image_uri, + ) + for result in results + ] + + return response + + +@app.get("/drops/{dropper_claim_id}", response_model=data.DropperClaimResponse) +async def get_drop_handler( + request: Request, + dropper_claim_id: str, + db_session: Session = Depends(db.yield_db_session), +) -> data.DropperClaimResponse: + """ + Get drop. + """ + + try: + drop = actions.get_drop( + db_session=db_session, dropper_claim_id=dropper_claim_id + ) + except NoResultFound: + raise EngineHTTPException(status_code=404, detail="No drops found.") + except Exception as e: + logger.error(f"Can't get drop {dropper_claim_id} end with error: {e}") + raise EngineHTTPException(status_code=500, detail="Can't get drop") + + return data.DropperClaimResponse( + id=drop.id, + dropper_contract_id=drop.dropper_contract_id, + title=drop.title, + description=drop.description, + active=drop.active, + claim_block_deadline=drop.claim_block_deadline, + terminus_address=drop.terminus_address, + terminus_pool_id=drop.terminus_pool_id, + claim_id=drop.claim_id, + ) + + +@app.get("/terminus") +async def get_drops_terminus_handler( + blockchain: str = Query(None), + db_session: Session = Depends(db.yield_db_session), +) -> List[data.DropperTerminusResponse]: + + """ + Return distinct terminus pools + """ + + try: + results = actions.list_drops_terminus( + db_session=db_session, blockchain=blockchain + ) + except Exception as e: + logger.error(f"Can't get list of terminus contracts end with error: {e}") + raise EngineHTTPException( + status_code=500, detail="Can't get terminus contracts" + ) + + response = [ + data.DropperTerminusResponse( + terminus_address=result.terminus_address, + terminus_pool_id=result.terminus_pool_id, + blockchain=result.blockchain, + ) + for result in results + ] + + return response diff --git a/engineapi/engineapi/scripts/__init__.py b/engineapi/engineapi/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engineapi/engineapi/scripts/fill_raw_amount.py b/engineapi/engineapi/scripts/fill_raw_amount.py new file mode 100644 index 00000000..a86eeadc --- /dev/null +++ b/engineapi/engineapi/scripts/fill_raw_amount.py @@ -0,0 +1,157 @@ +import argparse +import logging +from typing import Dict, List, Optional, Any + +from .. import db +from ..contracts import Dropper_interface, ERC20_interface +from ..settings import BLOCKCHAIN_WEB3_PROVIDERS, UNSUPPORTED_BLOCKCHAIN_ERROR_MESSAGE + + +def run_fill_raw_amount(args: argparse.Namespace): + # sync raw_amount column with amount column + + # create chache of claim token type + # newtwork contract and list of claims with their token type + + token_types: Dict[str, Dict[str, List[Dict[str, Any]]]] = dict() + + with db.yield_db_session_ctx() as db_session: + + res = db_session.execute( + """select distinct dropper_contracts.blockchain, dropper_contracts.address, dropper_claims.claim_id from dropper_contracts + left join dropper_claims on dropper_contracts.id = dropper_claims.dropper_contract_id + where dropper_claims.claim_id is not null""" + ) + results = res.fetchall() + + for blockchain, address, claim_id in results: + if blockchain not in token_types: + token_types[blockchain] = dict() + if address not in token_types[blockchain]: + token_types[blockchain][address] = list() + token_types[blockchain][address].append(claim_id) + + db_session.execute( + """ + create table temptest + ( + blockchain varchar, + address varchar, + claim_id varchar, + token_type varchar, + zeros varchar + ) + + """ + ) + + for blockchain in token_types: + if blockchain not in BLOCKCHAIN_WEB3_PROVIDERS: + logging.warn( + f"Blockchain: {blockchain}. {UNSUPPORTED_BLOCKCHAIN_ERROR_MESSAGE}" + ) + continue + for address in token_types[blockchain]: + dropper_contract = Dropper_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[blockchain], address + ) + + for claim_id in token_types[blockchain][address]: + claim_info = dropper_contract.getClaim(claim_id).call() + zeros = None + if claim_info[0] == 20: + erc20_contract = ERC20_interface.Contract( + BLOCKCHAIN_WEB3_PROVIDERS[blockchain], claim_info[1] + ) + zeros = "0" * erc20_contract.decimals() + + db_session.execute( + """ + insert into temptest + ( + blockchain, + address, + claim_id, + token_type, + zeros + + ) + values + ( + :blockchain, + :address, + :claim_id, + :token_type, + :zeros + ) + """, + { + "blockchain": blockchain, + "address": address, + "claim_id": str(claim_id), + "token_type": str(claim_info[0]), + "zeros": zeros, + }, + ) + + db_session.commit() + + # update raw_amount column + db_session.execute( + """ + update + dropper_claimants + set + raw_amount = ( + CASE + WHEN ( + select + DISTINCT temptest.token_type + from + temptest + inner join dropper_claims ON temptest.claim_id :: int = dropper_claims.claim_id + where + dropper_claims.id = dropper_claimants.dropper_claim_id + ) :: int = 20 THEN CASE + WHEN dropper_claimants.amount is not null + and dropper_claimants.amount > 0 THEN CONCAT( + CAST(dropper_claimants.amount as varchar), + ( + select + temptest.zeros + from + temptest + inner join dropper_claims ON temptest.claim_id :: int = dropper_claims.claim_id + where + dropper_claims.id = dropper_claimants.dropper_claim_id + ) + ) + WHEN true THEN CAST(dropper_claimants.amount as varchar) + END + WHEN true THEN CAST(dropper_claimants.amount as varchar) + END + ); + """ + ) + db_session.commit() + + +def main(): + parser = argparse.ArgumentParser( + description="dao: The command line interface to Moonstream DAO" + ) + parser.set_defaults(func=lambda _: parser.print_help()) + subparsers = parser.add_subparsers() + + run_fill_raw_amount_parser = subparsers.add_parser( + "fill_raw_amount", help="Fill raw_amount column" + ) + + run_fill_raw_amount_parser.set_defaults(func=run_fill_raw_amount) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/engineapi/engineapi/settings.py b/engineapi/engineapi/settings.py new file mode 100644 index 00000000..be2de443 --- /dev/null +++ b/engineapi/engineapi/settings.py @@ -0,0 +1,180 @@ +import os +import warnings + +from web3 import Web3, HTTPProvider +from web3.middleware import geth_poa_middleware +from bugout.app import Bugout + +# Bugout +BUGOUT_BROOD_URL = os.environ.get("BUGOUT_BROOD_URL", "https://auth.bugout.dev") +BUGOUT_SPIRE_URL = os.environ.get("BUGOUT_SPIRE_URL", "https://spire.bugout.dev") + +bugout_client = Bugout(brood_api_url=BUGOUT_BROOD_URL, spire_api_url=BUGOUT_SPIRE_URL) + + +ENGINE_DEV_RAW = os.environ.get("ENGINE_DEV", "") +ENGINE_DEV = True if ENGINE_DEV_RAW in {"1", "true", "yes", "t", "y"} else False + +# Authorized origins for CORS +RAW_ORIGINS = os.environ.get("ENGINE_CORS_ALLOWED_ORIGINS") +if RAW_ORIGINS is None: + raise ValueError( + "ENGINE_CORS_ALLOWED_ORIGINS environment variable must be set (comma-separated list of CORS allowed origins)" + ) +ORIGINS = RAW_ORIGINS.split(",") + +# Open API documentation path +DOCS_TARGET_PATH = os.environ.get("DOCS_TARGET_PATH", "docs") + + +# If SIGNER_KEYSTORE and SIGNER_PASSWORD are set, then we use the local signer. +# Otherwise, we use the AWS signer. +SIGNER_KEYSTORE = os.environ.get("SIGNER_KEYSTORE") +SIGNER_PASSWORD = os.environ.get("SIGNER_PASSWORD") + +MOONSTREAM_SIGNING_SERVER_IP = os.environ.get("MOONSTREAM_SIGNING_SERVER_IP", None) + +# Settings related to the AWS signer +AWS_DEFAULT_REGION = os.environ.get("AWS_DEFAULT_REGION") +if AWS_DEFAULT_REGION is None: + if not ENGINE_DEV: + raise ValueError("AWS_DEFAULT_REGION environment variable must be set") + else: + warnings.warn( + 'AWS_DEFAULT_REGION environment variable is not set. Using "us-east-1".' + ) + AWS_DEFAULT_REGION = "us-east-1" + +MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID = os.environ.get( + "MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID" +) +if MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID is None: + if not ENGINE_DEV: + raise ValueError( + "MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID environment variable must be set" + ) + else: + warnings.warn( + "MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID environment variable is not set." + ) + +MOONSTREAM_AWS_SIGNER_IMAGE_ID = os.environ.get("MOONSTREAM_AWS_SIGNER_IMAGE_ID") +if MOONSTREAM_AWS_SIGNER_IMAGE_ID is None: + if not ENGINE_DEV: + raise ValueError( + "MOONSTREAM_AWS_SIGNER_IMAGE_ID environment variable must be set" + ) + else: + warnings.warn("MOONSTREAM_AWS_SIGNER_IMAGE_ID environment is not set.") + +MOONSTREAM_AWS_SIGNER_INSTANCE_PORT = 17181 + +# Blockchain configuration + +MOONSTREAM_ETHEREUM_WEB3_PROVIDER_URI = os.environ.get( + "MOONSTREAM_ETHEREUM_WEB3_PROVIDER_URI" +) +MOONSTREAM_MUMBAI_WEB3_PROVIDER_URI = os.environ.get( + "MOONSTREAM_MUMBAI_WEB3_PROVIDER_URI" +) +MOONSTREAM_POLYGON_WEB3_PROVIDER_URI = os.environ.get( + "MOONSTREAM_POLYGON_WEB3_PROVIDER_URI" +) +MOONSTREAM_XDAI_WEB3_PROVIDER_URI = os.environ.get("MOONSTREAM_XDAI_WEB3_PROVIDER_URI") + +# TODO(kompotkot): Leave a comment here explaining templated *_WEB3_PROVIDER_URI when we set +# NODEBALANCER_ACCESS_ID +ETHEREUM_PROVIDER_URI = MOONSTREAM_ETHEREUM_WEB3_PROVIDER_URI +MUMBAI_PROVIDER_URI = MOONSTREAM_MUMBAI_WEB3_PROVIDER_URI +POLYGON_PROVIDER_URI = MOONSTREAM_POLYGON_WEB3_PROVIDER_URI +XDAI_PROVIDER_URI = MOONSTREAM_XDAI_WEB3_PROVIDER_URI + +NODEBALANCER_ACCESS_ID = os.environ.get("ENGINE_NODEBALANCER_ACCESS_ID") +if NODEBALANCER_ACCESS_ID is not None: + NODEBALANCER_URI_TEMPLATE = "{}?access_id={}&data_source=blockchain" + ETHEREUM_PROVIDER_URI = NODEBALANCER_URI_TEMPLATE.format( + MOONSTREAM_ETHEREUM_WEB3_PROVIDER_URI, NODEBALANCER_ACCESS_ID + ) + MUMBAI_PROVIDER_URI = NODEBALANCER_URI_TEMPLATE.format( + MOONSTREAM_MUMBAI_WEB3_PROVIDER_URI, NODEBALANCER_ACCESS_ID + ) + POLYGON_PROVIDER_URI = NODEBALANCER_URI_TEMPLATE.format( + MOONSTREAM_POLYGON_WEB3_PROVIDER_URI, NODEBALANCER_ACCESS_ID + ) + XDAI_PROVIDER_URI = NODEBALANCER_URI_TEMPLATE.format( + MOONSTREAM_XDAI_WEB3_PROVIDER_URI, NODEBALANCER_ACCESS_ID + ) + +BLOCKCHAIN_PROVIDER_URIS = { + "ethereum": ETHEREUM_PROVIDER_URI, + "mumbai": MUMBAI_PROVIDER_URI, + "polygon": POLYGON_PROVIDER_URI, + "xdai": XDAI_PROVIDER_URI, +} + +SUPPORTED_BLOCKCHAINS = ", ".join(BLOCKCHAIN_PROVIDER_URIS) +UNSUPPORTED_BLOCKCHAIN_ERROR_MESSAGE = f"That blockchain is not supported. The supported blockchains are: {SUPPORTED_BLOCKCHAINS}." + +BLOCKCHAIN_WEB3_PROVIDERS = { + blockchain: Web3(HTTPProvider(jsonrpc_uri)) + for blockchain, jsonrpc_uri in BLOCKCHAIN_PROVIDER_URIS.items() +} + +# For Proof-of-Authority chains (e.g. Polygon), inject the geth_poa_middleware into the web3 client: +# https://web3py.readthedocs.io/en/stable/middleware.html#geth-style-proof-of-authority +# For every chain represented in BLOCKCHAIN_WEB3_PROVIDERS and BLOCKCHAIN_PROVIDER_URIS, if the chain +# is a proof-of-authority chain, add it to the POA_CHAINS list, as well. +POA_CHAINS = ["mumbai", "polygon"] +for chain in POA_CHAINS: + BLOCKCHAIN_WEB3_PROVIDERS[chain].middleware_onion.inject( + geth_poa_middleware, layer=0 + ) + +# Database +ENGINE_DB_URI = os.environ.get("ENGINE_DB_URI") +if ENGINE_DB_URI is None: + raise ValueError("ENGINE_DB_URI environment variable must be set") + +ENGINE_DB_URI_READ_ONLY = os.environ.get("ENGINE_DB_URI_READ_ONLY") +if ENGINE_DB_URI_READ_ONLY is None: + raise ValueError("ENGINE_DB_URI_READ_ONLY environment variable must be set") + +ENGINE_POOL_SIZE_RAW = os.environ.get("ENGINE_POOL_SIZE") +ENGINE_POOL_SIZE = 0 +try: + if ENGINE_POOL_SIZE_RAW is not None: + ENGINE_POOL_SIZE = int(ENGINE_POOL_SIZE_RAW) +except: + raise Exception(f"Could not parse ENGINE_POOL_SIZE as int: {ENGINE_POOL_SIZE_RAW}") + +ENGINE_DB_STATEMENT_TIMEOUT_MILLIS_RAW = os.environ.get( + "ENGINE_DB_STATEMENT_TIMEOUT_MILLIS" +) +ENGINE_DB_STATEMENT_TIMEOUT_MILLIS = 30000 +try: + if ENGINE_DB_STATEMENT_TIMEOUT_MILLIS_RAW is not None: + ENGINE_DB_STATEMENT_TIMEOUT_MILLIS = int(ENGINE_DB_STATEMENT_TIMEOUT_MILLIS_RAW) +except: + raise ValueError( + f"ENGINE_DB_STATEMENT_TIMEOUT_MILLIOS must be an integer: {ENGINE_DB_STATEMENT_TIMEOUT_MILLIS_RAW}" + ) + +ENGINE_DB_POOL_RECYCLE_SECONDS_RAW = os.environ.get("ENGINE_DB_POOL_RECYCLE_SECONDS") +ENGINE_DB_POOL_RECYCLE_SECONDS = 1800 +try: + if ENGINE_DB_POOL_RECYCLE_SECONDS_RAW is not None: + ENGINE_DB_POOL_RECYCLE_SECONDS = int(ENGINE_DB_POOL_RECYCLE_SECONDS_RAW) +except: + raise ValueError( + f"ENGINE_DB_POOL_RECYCLE_SECONDS must be an integer: {ENGINE_DB_POOL_RECYCLE_SECONDS_RAW}" + ) + +MOONSTREAM_APPLICATION_ID = os.environ.get("MOONSTREAM_APPLICATION_ID", "") +if MOONSTREAM_APPLICATION_ID == "": + raise ValueError("MOONSTREAM_APPLICATION_ID environment variable must be set") + +LEADERBOARD_RESOURCE_TYPE = "leaderboard" + +MOONSTREAM_ADMIN_ACCESS_TOKEN = os.environ.get("MOONSTREAM_ADMIN_ACCESS_TOKEN", "") +if MOONSTREAM_ADMIN_ACCESS_TOKEN == "": + raise ValueError("MOONSTREAM_ADMIN_ACCESS_TOKEN environment variable must be set") diff --git a/engineapi/engineapi/signatures.py b/engineapi/engineapi/signatures.py new file mode 100644 index 00000000..b9a19c19 --- /dev/null +++ b/engineapi/engineapi/signatures.py @@ -0,0 +1,326 @@ +""" +Signing and signature verification functionality and interfaces. +""" +import abc +import logging +import json +from typing import Any, List, Optional, Union + +import boto3 +from web3 import Web3 + +from eth_account import Account +from eth_account.messages import encode_defunct +from eth_account._utils.signing import sign_message_hash +import eth_keys +import requests +from hexbytes import HexBytes + +from .settings import ( + SIGNER_KEYSTORE, + SIGNER_PASSWORD, + MOONSTREAM_SIGNING_SERVER_IP, + AWS_DEFAULT_REGION, + MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID, + MOONSTREAM_AWS_SIGNER_IMAGE_ID, + MOONSTREAM_AWS_SIGNER_INSTANCE_PORT, +) + +logger = logging.getLogger(__name__) + +aws_client = boto3.client("ec2", region_name=AWS_DEFAULT_REGION) + + +class AWSDescribeInstancesFail(Exception): + """ + Raised when AWS describe instances command failed. + """ + + +class AWSRunInstancesFail(Exception): + """ + Raised when AWS run instances command failed. + """ + + +class AWSTerminateInstancesFail(Exception): + """ + Raised when AWS terminate instances command failed. + """ + + +class SigningInstancesNotFound(Exception): + """ + Raised when signing instances with the given ids is not found in at AWS. + """ + + +class SigningInstancesTerminationLimitExceeded(Exception): + """ + Raised when provided several instances to termination. + """ + + +class SignWithInstanceFail(Exception): + """ + Raised when failed signing of message with instance server. + """ + + +class Signer: + @abc.abstractmethod + def sign_message(self, message): + pass + + @abc.abstractmethod + def refresh_signer(self): + pass + + @abc.abstractmethod + def batch_sign_message(self, messages_list): + pass + + +class AccountSigner(Signer): + """ + Simple implementation of a signer that uses a Brownie account to sign messages. + """ + + def __init__(self, private_key: HexBytes) -> None: + self.private_key = private_key + + def sign_message(self, message): + eth_private_key = eth_keys.keys.PrivateKey(self.private_key) + message_hash_bytes = HexBytes(message) + _, _, _, signed_message_bytes = sign_message_hash( + eth_private_key, message_hash_bytes + ) + return signed_message_bytes.hex() + + def batch_sign_message(self, messages_list: List[str]): + + signed_messages_list = {} + + for message in messages_list: + eth_private_key = eth_keys.keys.PrivateKey(self.private_key) + message_hash_bytes = HexBytes(message) + _, _, _, signed_message_bytes = sign_message_hash( + eth_private_key, message_hash_bytes + ) + signed_messages_list[message.hex()] = signed_message_bytes.hex() + + return signed_messages_list + + +def create_account_signer(keystore: str, password: str) -> AccountSigner: + with open(keystore) as keystore_file: + keystore_data = json.load(keystore_file) + private_key = Account.decrypt(keystore_data, password) + signer = AccountSigner(private_key) + return signer + + +class InstanceSigner(Signer): + """ + AWS instance server signer. + """ + + def __init__(self, ip: Optional[str] = None) -> None: + self.current_signer_uri = None + if ip is not None: + self.current_signer_uri = ( + f"http://{ip}:{MOONSTREAM_AWS_SIGNER_INSTANCE_PORT}/sign" + ) + self.current_signer_batch_uri = ( + f"http://{ip}:{MOONSTREAM_AWS_SIGNER_INSTANCE_PORT}/batchsign" + ) + + def clean_signer(self) -> None: + self.current_signer_uri = None + self.current_signer_batch_uri = None + + def refresh_signer(self) -> None: + try: + instances = list_signing_instances([]) + except AWSDescribeInstancesFail: + raise AWSDescribeInstancesFail("AWS describe instances command failed") + except Exception as err: + logger.error(f"AWS describe instances command failed: {err}") + raise SignWithInstanceFail("AWS describe instances command failed") + + if len(instances) != 1: + raise SignWithInstanceFail("Unsupported number of signing instances") + + self.current_signer_uri = f"http://{instances[0]['private_ip_address']}:{MOONSTREAM_AWS_SIGNER_INSTANCE_PORT}/sign" + self.current_signer_batch_uri = f"http://{instances[0]['private_ip_address']}:{MOONSTREAM_AWS_SIGNER_INSTANCE_PORT}/batchsign" + + def sign_message(self, message: str): + # TODO(kompotkot): What to do if self.current_signer_uri is not None but the signing server went down? + if self.current_signer_uri is None: + self.refresh_signer() + + signed_message = "" + try: + resp = requests.post( + self.current_signer_uri, + headers={"Content-Type": "application/json"}, + json={"unsigned_data": str(message)}, + ) + resp.raise_for_status() + body = resp.json() + signed_message = body["signed_data"] + except Exception as err: + logger.error(f"Failed signing of message with instance server, {err}") + raise SignWithInstanceFail("Failed signing of message with instance server") + + # Hack as per: https://medium.com/@yaoshiang/ethereums-ecrecover-openzeppelin-s-ecdsa-and-web3-s-sign-8ff8d16595e1 + signature = signed_message[2:] + if signature[-2:] == "00": + signature = f"{signature[:-2]}1b" + elif signature[-2:] == "01": + signature = f"{signature[:-2]}1c" + else: + raise SignWithInstanceFail( + f"Unexpected v-value on signed message: {signed_message[-2:]}" + ) + + return signature + + def batch_sign_message(self, messages_list: List[str]): + if self.current_signer_uri is None: + self.refresh_signer() + + try: + resp = requests.post( + self.current_signer_batch_uri, + headers={"Content-Type": "application/json"}, + json={"unsigned_data": [str(message) for message in messages_list]}, + ) + resp.raise_for_status() + signed_messages = resp.json()["signed_data"] + except Exception as err: + logger.error(f"Failed signing of message with instance server, {err}") + raise SignWithInstanceFail("Failed signing of message with instance server") + + results = {} + + # Hack as per: https://medium.com/@yaoshiang/ethereums-ecrecover-openzeppelin-s-ecdsa-and-web3-s-sign-8ff8d16595e1 + for unsigned_message, signed_message in signed_messages.items(): + signature = signed_message[2:] + if signature[-2:] == "00": + signature = f"{signature[:-2]}1b" + elif signature[-2:] == "01": + signature = f"{signature[:-2]}1c" + else: + raise SignWithInstanceFail( + f"Unexpected v-value on signed message: {signed_message[-2:]}" + ) + results[unsigned_message] = signature + + return results + + +DROP_SIGNER: Optional[Signer] = None +if SIGNER_KEYSTORE is not None and SIGNER_PASSWORD is not None: + DROP_SIGNER = create_account_signer(SIGNER_KEYSTORE, SIGNER_PASSWORD) +if DROP_SIGNER is None: + DROP_SIGNER = InstanceSigner(MOONSTREAM_SIGNING_SERVER_IP) + + +def list_signing_instances( + signing_instances: List[str], +) -> List[Any]: + """ + Return a list of signing instances with IPs. + """ + described_instances = [] + try: + described_instances_response = aws_client.describe_instances( + Filters=[ + {"Name": "image-id", "Values": [MOONSTREAM_AWS_SIGNER_IMAGE_ID]}, + {"Name": "tag:Application", "Values": ["signer"]}, + ], + InstanceIds=signing_instances, + ) + for r in described_instances_response["Reservations"]: + for i in r["Instances"]: + described_instances.append( + { + "instance_id": i["InstanceId"], + "private_ip_address": i["PrivateIpAddress"], + } + ) + except Exception as err: + logger.error(f"AWS describe instances command failed: {err}") + raise AWSDescribeInstancesFail("AWS describe instances command failed.") + + return described_instances + + +def wakeup_signing_instances(run_confirmed=False, dry_run=True) -> List[str]: + """ + Run new signing instances. + """ + run_instances = [] + if run_confirmed: + try: + run_instances_response = aws_client.run_instances( + LaunchTemplate={ + "LaunchTemplateId": MOONSTREAM_AWS_SIGNER_LAUNCH_TEMPLATE_ID + }, + MinCount=1, + MaxCount=1, + DryRun=dry_run, + ) + for i in run_instances_response["Instances"]: + run_instances.append(i["InstanceId"]) + except Exception as err: + logger.error(f"AWS run instances command failed: {err}") + raise AWSRunInstancesFail("AWS run instances command failed") + + return run_instances + + +def sleep_signing_instances( + signing_instances: List[str], termination_confirmed=False, dry_run=True +) -> List[str]: + """ + Fetch, describe, verify signing instances and terminate them. + """ + if len(signing_instances) == 0: + raise SigningInstancesNotFound("There are no signing instances to describe") + + described_instances = [] + try: + described_instances_response = list_signing_instances(signing_instances) + for i in described_instances_response: + described_instances.append(i["instance_id"]) + except Exception as err: + logger.error(f"AWS describe instances command failed: {err}") + raise AWSDescribeInstancesFail("AWS describe instances command failed.") + + if len(described_instances) == 0: + raise SigningInstancesNotFound( + "Signing instances with the given ids is not found in at AWS." + ) + if len(described_instances) > 1: + raise SigningInstancesTerminationLimitExceeded( + f"Provided {len(described_instances)} instances to termination" + ) + + terminated_instances = [] + if termination_confirmed: + try: + terminated_instances_response = aws_client.terminate_instances( + InstanceIds=described_instances, + DryRun=dry_run, + ) + for i in terminated_instances_response["TerminatingInstances"]: + terminated_instances.append(i["InstanceId"]) + except Exception as err: + logger.error( + f"Unable to terminate instance {described_instances}, error: {err}" + ) + raise AWSTerminateInstancesFail("AWS terminate instances command failed") + + return terminated_instances diff --git a/engineapi/engineapi/test_auth.py b/engineapi/engineapi/test_auth.py new file mode 100644 index 00000000..0238404d --- /dev/null +++ b/engineapi/engineapi/test_auth.py @@ -0,0 +1,55 @@ +import time +import unittest + +from brownie import network, accounts +from hexbytes import HexBytes + +from .auth import ( + authorize, + verify, + MoonstreamAuthorizationVerificationError, + MoonstreamAuthorizationExpired, +) + + +class TestAuth(unittest.TestCase): + @classmethod + def setUpClass(cls): + try: + network.connect() + except: + pass + cls.signer = accounts.add() + + cls.non_signer = accounts.add() + + def test_authorization_and_verification(self): + current_time = int(time.time()) + payload = authorize( + current_time + 300, self.signer.address, HexBytes(self.signer.private_key) + ) + self.assertDictContainsSubset( + {"address": self.signer.address, "deadline": current_time + 300}, payload + ) + self.assertTrue(verify(payload)) + + def test_authorization_and_verification_fails_for_wrong_address(self): + current_time = int(time.time()) + payload = authorize( + current_time + 300, self.signer.address, HexBytes(self.signer.private_key) + ) + payload["address"] = self.non_signer.address + with self.assertRaises(MoonstreamAuthorizationVerificationError): + verify(payload) + + def test_authorization_and_verification_fails_after_deadline(self): + current_time = int(time.time()) + payload = authorize( + current_time - 1, self.signer.address, HexBytes(self.signer.private_key) + ) + with self.assertRaises(MoonstreamAuthorizationExpired): + verify(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/engineapi/engineapi/version.py b/engineapi/engineapi/version.py new file mode 100644 index 00000000..d6ffd744 --- /dev/null +++ b/engineapi/engineapi/version.py @@ -0,0 +1,11 @@ +import os + +VERSION = "UNKNOWN" + +try: + PATH = os.path.abspath(os.path.dirname(__file__)) + VERSION_FILE = os.path.join(PATH, "version.txt") + with open(VERSION_FILE) as ifp: + VERSION = ifp.read().strip() +except: + pass diff --git a/engineapi/engineapi/version.txt b/engineapi/engineapi/version.txt new file mode 100644 index 00000000..81340c7e --- /dev/null +++ b/engineapi/engineapi/version.txt @@ -0,0 +1 @@ +0.0.4 diff --git a/engineapi/requirements.txt b/engineapi/requirements.txt new file mode 100644 index 00000000..63b65483 --- /dev/null +++ b/engineapi/requirements.txt @@ -0,0 +1,76 @@ +aiohttp==3.8.3 +aiosignal==1.2.0 +alembic==1.8.1 +anyio==3.6.2 +async-timeout==4.0.2 +attrs==22.1.0 +base58==2.1.1 +bitarray==2.6.0 +black==22.10.0 +boto3==1.24.93 +botocore==1.27.93 +Brownie==0.5.1 +bugout==0.2.2 +certifi==2022.9.24 +charset-normalizer==2.1.1 +click==8.1.3 +cytoolz==0.12.0 +dataclassy==0.11.1 +eip712==0.1.0 +eth-abi==2.2.0 +eth-account==0.5.9 +eth-hash==0.5.0 +eth-keyfile==0.5.1 +eth-keys==0.3.4 +eth-rlp==0.2.1 +eth-typing==2.3.0 +eth-utils==1.9.5 +fastapi==0.85.1 +frozenlist==1.3.1 +greenlet==1.1.3.post0 +h11==0.14.0 +hexbytes==0.2.3 +idna==3.4 +importlib-metadata==5.0.0 +importlib-resources==5.10.0 +ipfshttpclient==0.8.0a2 +isort==5.10.1 +jmespath==1.0.1 +jsonschema==4.16.0 +lru-dict==1.1.8 +Mako==1.2.3 +MarkupSafe==2.1.1 +multiaddr==0.0.9 +multidict==6.0.2 +mypy==0.982 +mypy-extensions==0.4.3 +netaddr==0.8.0 +parsimonious==0.8.1 +pathspec==0.10.1 +pkgutil_resolve_name==1.3.10 +platformdirs==2.5.2 +protobuf==3.19.5 +psycopg2-binary==2.9.4 +pycryptodome==3.15.0 +pydantic==1.10.2 +pyrsistent==0.18.1 +python-dateutil==2.8.2 +requests==2.28.1 +rlp==2.0.1 +s3transfer==0.6.0 +six==1.16.0 +sniffio==1.3.0 +SQLAlchemy==1.4.42 +starlette==0.20.4 +tabulate==0.9.0 +tomli==2.0.1 +toolz==0.12.0 +tqdm==4.64.1 +typing_extensions==4.4.0 +urllib3==1.26.12 +uvicorn==0.18.3 +varint==1.0.2 +web3==5.31.1 +websockets==9.1 +yarl==1.8.1 +zipp==3.9.0 diff --git a/engineapi/setup.py b/engineapi/setup.py new file mode 100644 index 00000000..173b8df0 --- /dev/null +++ b/engineapi/setup.py @@ -0,0 +1,51 @@ +from setuptools import find_packages, setup + +with open("engineapi/version.txt") as ifp: + VERSION = ifp.read().strip() + +long_description = "" +with open("README.md") as ifp: + long_description = ifp.read() + +setup( + name="engineapi", + version=VERSION, + packages=find_packages(), + install_requires=[ + "boto3", + "bugout>=0.2.2", + "eip712==0.1.0", + "eth-typing>=2.3.0", + "fastapi", + "psycopg2-binary", + "pydantic", + "sqlalchemy", + "tqdm", + "uvicorn", + "web3>=5.30.0, <6", + "tabulate", + ], + extras_require={ + "dev": ["alembic", "black", "mypy", "isort"], + "distribute": ["setuptools", "twine", "wheel"], + }, + description="Command line interface for Moonstream Engine API", + long_description=long_description, + long_description_content_type="text/markdown", + author="Moonstream", + author_email="engineering@moonstream.to", + classifiers=[ + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "License :: OSI Approved :: Apache Software License", + "Topic :: Software Development :: Libraries", + ], + python_requires=">=3.8", + entry_points={ + "console_scripts": [ + "engineapi=engineapi.cli:main", + ] + }, + package_data={"engineapi": ["contracts/*.json"]}, + include_package_data=True, +)