diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index fafd254..048681e 100644 --- a/.github/workflows/publish-api.yaml +++ b/.github/workflows/publish-api.yaml @@ -78,6 +78,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} eks-deployment-restart: + # Run job on branch dev only + if: github.ref == 'refs/heads/dev' runs-on: ubuntu-latest needs: build-and-push-image permissions: diff --git a/.github/workflows/publish-ui.yaml b/.github/workflows/publish-ui.yaml index e5e4402..0122754 100644 --- a/.github/workflows/publish-ui.yaml +++ b/.github/workflows/publish-ui.yaml @@ -71,6 +71,8 @@ jobs: "REACT_APP_FAUCET_API_URL=${{ secrets.PROD_REACT_APP_FAUCET_API_URL}}" eks-deployment-restart: + # Run job on branch dev only + if: github.ref == 'refs/heads/dev' runs-on: ubuntu-latest needs: build-and-push-image permissions: diff --git a/api/.env.example b/api/.env.example index a444063..69890e1 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,7 +2,7 @@ FAUCET_AMOUNT=0.1 FAUCET_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 FAUCET_RPC_URL=https://rpc.chiadochain.net FAUCET_CHAIN_ID=10200 +FAUCET_DATABASE_URI=sqlite:// FAUCET_ENABLED_TOKENS="[{\"address\": \"0x19C653Da7c37c66208fbfbE8908A5051B57b4C70\", \"name\":\"GNO\", \"maximumAmount\": 0.5}]" -# FAUCET_ENABLED_TOKENS= CAPTCHA_VERIFY_ENDPOINT=https://api.hcaptcha.com/siteverify CAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000 \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 2ae5386..9e03a68 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,9 +1,20 @@ -FROM python:3.8-alpine +FROM python:3.10-alpine -COPY requirements.txt /tmp/requirements.txt -RUN pip install --no-cache-dir -r /tmp/requirements.txt - -COPY . /api +ENV PYTHONUNBUFFERED 1 WORKDIR /api -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "api:create_app()"] \ No newline at end of file +COPY requirements.txt ./ + +# Signal handling for PID1 https://github.com/krallin/tini +RUN apk add --update --no-cache tini libpq && \ + apk add --no-cache --virtual .build-dependencies alpine-sdk libffi-dev && \ + pip install --no-cache-dir -r requirements.txt && \ + apk del .build-dependencies && \ + find /usr/local \ + \( -type d -a -name test -o -name tests \) \ + -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \ + -exec rm -rf '{}' + + +COPY . . + +ENTRYPOINT ["/sbin/tini", "--"] \ No newline at end of file diff --git a/api/api/api.py b/api/api/api.py index bff7e1d..201a764 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -2,10 +2,12 @@ from flask import Flask from flask_cors import CORS +from flask_migrate import Migrate +from .manage import create_access_keys_cmd from .routes import apiv1 -from .services import Cache, Web3Singleton -from .services.database import db, migrate +from .services import Web3Singleton +from .services.database import db def setup_logger(log_level): @@ -32,17 +34,16 @@ def create_app(): app = Flask(__name__) # Initialize main settings app.config.from_object('api.settings') - # Initialize Cache - app.config['FAUCET_CACHE'] = Cache(app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']) # Initialize API Routes app.register_blueprint(apiv1, url_prefix="/api/v1") + # Add cli commands + app.cli.add_command(create_access_keys_cmd) with app.app_context(): db.init_app(app) - migrate.init_app(app, db) - db.create_all() # Create database tables for our data models + Migrate(app, db) - # Initialize Web3 class + # Initialize Web3 class for latter usage w3 = Web3Singleton(app.config['FAUCET_RPC_URL'], app.config['FAUCET_PRIVATE_KEY']) setup_cors(app) diff --git a/api/api/const.py b/api/api/const.py index 5c6a67e..341f849 100644 --- a/api/api/const.py +++ b/api/api/const.py @@ -1 +1,16 @@ -NATIVE_TOKEN_ADDRESS='native' +from enum import Enum + +NATIVE_TOKEN_ADDRESS = 'native' +DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY = 0.01 +DEFAULT_ERC20_MAX_AMOUNT_PER_DAY = 0.01 + +CHAIN_NAMES = { + 1: 'ETHEREUM MAINNET', + 100: 'GNOSIS CHAIN', + 10200: 'CHIADO CHAIN' +} + + +class FaucetRequestType(Enum): + web = 'web' + cli = 'cli' diff --git a/api/api/manage.py b/api/api/manage.py new file mode 100644 index 0000000..451b591 --- /dev/null +++ b/api/api/manage.py @@ -0,0 +1,26 @@ +import logging + +import click +from flask import current_app +from flask.cli import with_appcontext + +from .services.database import AccessKey, AccessKeyConfig +from .utils import generate_access_key + + +@click.command(name='create_access_keys') +@with_appcontext +def create_access_keys_cmd(): + access_key_id, secret_access_key = generate_access_key() + access_key = AccessKey() + access_key.access_key_id = access_key_id + access_key.secret_access_key = secret_access_key + access_key.save() + + config = AccessKeyConfig() + config.access_key_id = access_key.access_key_id + config.chain_id = current_app.config["FAUCET_CHAIN_ID"] + config.save() + + logging.info(f'Access Key ID : ${access_key_id}') + logging.info(f'Secret access key: ${secret_access_key}') diff --git a/api/api/routes.py b/api/api/routes.py index 157eb37..52ffe26 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -1,10 +1,12 @@ +from datetime import datetime + from flask import Blueprint, current_app, jsonify, request from web3 import Web3 +from .const import FaucetRequestType from .services import (Strategy, Web3Singleton, captcha_verify, claim_native, claim_token) -from .services.database import AccessKey -from .utils import is_amount_valid, is_token_enabled +from .services.database import AccessKey, Token, Transaction apiv1 = Blueprint("version1", "version1") @@ -16,21 +18,42 @@ def status(): @apiv1.route("/info") def info(): + enabled_tokens = Token.enabled_tokens() + enabled_tokens_json = [ + { + 'address': t.address, + 'name': t.name, + 'maximumAmount': t.max_amount_day + } for t in enabled_tokens + ] return jsonify( - enabledTokens=current_app.config['FAUCET_ENABLED_TOKENS'], + enabledTokens=enabled_tokens_json, chainId=current_app.config['FAUCET_CHAIN_ID'], chainName=current_app.config['FAUCET_CHAIN_NAME'], faucetAddress=current_app.config['FAUCET_ADDRESS'] ), 200 -def _ask_route_validation(request_data, validate_captcha): +def _ask_route_validation(request_data, validate_captcha=True): + """Validate `ask/` endpoint request data + + Args: + request_data (object): request object + validate_captcha (bool, optional): True if captcha must be validated, False otherwise. Defaults to True. + + Returns: + tuple: validation errors, amount, recipient, token address + """ validation_errors = [] # Captcha validation if validate_captcha: # check hcatpcha - catpcha_verified = captcha_verify(request_data.get('captcha'), current_app.config['CAPTCHA_VERIFY_ENDPOINT'], current_app.config['CAPTCHA_SECRET_KEY']) + catpcha_verified = captcha_verify( + request_data.get('captcha'), + current_app.config['CAPTCHA_VERIFY_ENDPOINT'], current_app.config['CAPTCHA_SECRET_KEY'] + ) + if not catpcha_verified: validation_errors.append('captcha: validation failed') @@ -44,52 +67,78 @@ def _ask_route_validation(request_data, validate_captcha): if not recipient or recipient.lower() == current_app.config['FAUCET_ADDRESS']: validation_errors.append('recipient: address cant\'t be the Faucet address itself') + amount = request_data.get('amount', None) token_address = request_data.get('tokenAddress', None) - if not token_address: + + if token_address: + try: + # Clean up Token address + if token_address.lower() != 'native': + token_address = Web3.to_checksum_address(token_address) + + token = Token.query.with_entities(Token.enabled,Token.max_amount_day).filter_by( + address=token_address, + chain_id=request_data.get('chainId')).first() + + if token and token.enabled is True: + if not amount: + validation_errors.append('amount: is required') + if amount and amount > token.max_amount_day: + validation_errors.append('amount: a valid amount must be specified and must be less or equals to %s' % token[1]) + # except ValueError as e: + # message = "".join([arg for arg in e.args]) + # validation_errors.append(message) + else: + validation_errors.append('tokenAddress: %s is not enabled' % token_address) + except: + validation_errors.append('tokenAddress: invalid token address'), 400 + else: validation_errors.append('tokenAddress: A valid token address or string \"native\" must be specified') + return validation_errors, amount, recipient, token_address - try: - if not is_token_enabled(token_address, current_app.config['FAUCET_ENABLED_TOKENS']): - validation_errors.append('tokenAddress: Token %s is not enabled' % token_address) - except: - validation_errors.append('tokenAddress: invalid token address'), 400 - amount = request_data.get('amount', None) +def _ask(request_data, validate_captcha=True, access_key=None): + """Process /ask request - try: - amount_valid, amount_limit = is_amount_valid(amount, token_address, current_app.config['FAUCET_ENABLED_TOKENS']) - if not amount_valid: - validation_errors.append('amount: a valid amount must be specified and must be less or equals to %s' % amount_limit) - except ValueError as e: - message = "".join([arg for arg in e.args]) - validation_errors.append(message) - - return validation_errors, amount, recipient, token_address + Args: + request_data (object): request object + validate_captcha (bool, optional): True if captcha must be validated, False otherwise. Defaults to True. + access_key (object, optional): AccessKey instance. Defaults to None. + Raises: + NotImplementedError: -def _ask(request_data, validate_captcha): + Returns: + tuple: json content, status code + """ validation_errors, amount, recipient, token_address = _ask_route_validation(request_data, validate_captcha) if len(validation_errors) > 0: return jsonify(errors=validation_errors), 400 - # Cache - cache = current_app.config['FAUCET_CACHE'] + ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + if current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.address.value: - # Check last claim - if cache.limit_by_address(recipient): - return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %s hours' % cache.ttl(hours=True)]), 429 + # Check last claim by recipient + transaction = Transaction.last_by_recipient(recipient) elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip.value: - ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) - # Check last claim for the IP address - if cache.limit_by_ip(ip_address): - return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %s hours' % cache.ttl(hours=True)]), 429 + # Check last claim by IP + transaction = Transaction.last_by_ip(ip_address) elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_and_address: raise NotImplementedError + # Check if the recipient can claim funds, they must not have claimed any tokens + # in the period of time defined by FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS + if transaction: + time_diff_seconds = (datetime.utcnow() - transaction.created).total_seconds() + if time_diff_seconds < current_app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']: + time_diff_hours = time_diff_seconds/(24*60) + return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %d hours' % time_diff_hours]), 429 + + # convert amount to wei format amount_wei = Web3.to_wei(amount, 'ether') try: - # convert to checksum address + # convert recipient address to checksum address recipient = Web3.to_checksum_address(recipient) w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'], current_app.config['FAUCET_PRIVATE_KEY']) @@ -98,6 +147,21 @@ def _ask(request_data, validate_captcha): tx_hash = claim_native(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei) else: tx_hash = claim_token(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei, token_address) + + # save transaction data on DB + transaction = Transaction() + transaction.hash = tx_hash + transaction.recipient = recipient + transaction.amount = amount + transaction.token = token_address + transaction.requester_ip = ip_address + if access_key: + transaction.type = FaucetRequestType.cli.value + transaction.access_key_id = access_key.access_key_id + else: + transaction.type = FaucetRequestType.web.value + transaction.save() + return jsonify(transactionHash=tx_hash), 200 except ValueError as e: message = "".join([arg['message'] for arg in e.args]) @@ -106,13 +170,14 @@ def _ask(request_data, validate_captcha): @apiv1.route("/ask", methods=["POST"]) def ask(): - return _ask(request.get_json(), validate_captcha=True) + data, status_code = _ask(request.get_json(), validate_captcha=True, access_key=None) + return data, status_code @apiv1.route("/cli/ask", methods=["POST"]) def cli_ask(): - access_key_id = request.headers.get('FAUCET_ACCESS_KEY_ID', None) - secret_access_key = request.headers.get('FAUCET_SECRET_ACCESS_KEY', None) + access_key_id = request.headers.get('X-faucet-access-key-id', None) + secret_access_key = request.headers.get('X-faucet-secret-access-key', None) validation_errors = [] @@ -127,4 +192,5 @@ def cli_ask(): validation_errors.append('Access denied') return jsonify(errors=validation_errors), 403 - return _ask(request.get_json(), validate_captcha=False) \ No newline at end of file + data, status_code = _ask(request.get_json(), validate_captcha=False, access_key=access_key) + return data, status_code diff --git a/api/api/services/__init__.py b/api/api/services/__init__.py index 22cac16..93a405f 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -1,4 +1,3 @@ -from .cache import Cache from .captcha import captcha_verify from .database import DatabaseSingleton from .rate_limit import RateLimitStrategy, Strategy diff --git a/api/api/services/cache.py b/api/api/services/cache.py deleted file mode 100644 index 00a3317..0000000 --- a/api/api/services/cache.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime, timedelta - -from cachetools import TTLCache - - -class Cache: - def __init__(self, limit_seconds): - self._ttl = limit_seconds - self.cache = TTLCache(maxsize=10, ttl=timedelta(seconds=limit_seconds), timer=datetime.now) - - def limit_by_address(self, address): - cached = self.cache.get(address, False) - if not cached: - self.cache[address] = datetime.now() - return cached - - def limit_by_ip(self, ip): - cached = self.cache.get(ip, False) - if not cached: - self.cache[ip] = datetime.now() - return cached - - def delete(self, attr): - self.cache.pop(attr) - - def clear(self): - self.cache.clear() - - def ttl(self, hours=False): - if hours: - # 3600 seconds = 1h - return self._ttl // 3600 - return self._ttl \ No newline at end of file diff --git a/api/api/services/database.py b/api/api/services/database.py index 17a93d2..a749655 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -1,10 +1,12 @@ import sqlite3 +from datetime import datetime -from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy +from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) + db = SQLAlchemy() -migrate = Migrate() class Database: @@ -65,11 +67,68 @@ def delete(self, commit=True): db.session.commit() +class Token(BaseModel): + name = db.Column(db.String(10), nullable=False) + chain_id = db.Column(db.Integer, nullable=False) + address = db.Column(db.String(42), primary_key=True) + enabled = db.Column(db.Boolean, default=True, nullable=False) + max_amount_day = db.Column(db.Integer, nullable=False) + type = db.Column(db.String(6), nullable=False) # native, erc20 + + __tablename__ = "tokens" + + @classmethod + def enabled_tokens(cls): + return cls.query.with_entities(cls.name, + cls.address, + cls.chain_id).filter_by(enabled=True).all() + + class AccessKey(BaseModel): - __tablename__ = "access_keys" access_key_id = db.Column(db.String(16), primary_key=True) - secret_access_key = db.Column(db.String(32)) - enabled = db.Column(db.Boolean(), default=True) + secret_access_key = db.Column(db.String(32), nullable=False) + enabled = db.Column(db.Boolean, default=True, nullable=False) + + __tablename__ = "access_keys" def __repr__(self): return f"" + + +class AccessKeyConfig(BaseModel): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + erc20_max_amount_day = db.Column(db.Integer, nullable=False, default=DEFAULT_ERC20_MAX_AMOUNT_PER_DAY) + native_max_amount_day = db.Column(db.Integer, nullable=False, default=DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY) + access_key_id = db.Column(db.String, db.ForeignKey('access_keys.access_key_id')) + chain_id = db.Column(db.Integer, nullable=False) + + __tablename__ = "access_keys_config" + __table_args__ = tuple( + db.PrimaryKeyConstraint('access_key_id', 'chain_id') + ) + + +class Transaction(BaseModel): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + hash = db.Column(db.String(32), nullable=False) + recipient = db.Column(db.String(42), nullable=False) + amount = db.Column(db.Float, nullable=False) + token = db.Column(db.String, db.ForeignKey('tokens.address')) + type = db.Column(db.String(10), nullable=False, default=FaucetRequestType.web.value) + access_key_id = db.Column(db.String, db.ForeignKey('access_keys.access_key_id'), nullable=True) + requester_ip = db.Column(db.String, nullable=False) + created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + updated = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + __tablename__ = "transactions" + __table_args__ = tuple( + db.PrimaryKeyConstraint('hash', 'token') + ) + + @classmethod + def last_by_recipient(cls, recipient): + return cls.query.filter_by(recipient=recipient).order_by(cls.created.desc()).first() + + @classmethod + def last_by_ip(cls, ip): + return cls.query.filter_by(requester_ip=ip).order_by(cls.created.desc()).first() diff --git a/api/api/settings.py b/api/api/settings.py index 97e6210..45d0bd5 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -5,8 +5,10 @@ from eth_account import Account from eth_account.signers.local import LocalAccount +from .const import CHAIN_NAMES from .services import RateLimitStrategy -from .utils import get_chain_name + +# from .utils import get_chain_name load_dotenv() @@ -15,26 +17,15 @@ FAUCET_RPC_URL = os.getenv("FAUCET_RPC_URL") FAUCET_PRIVATE_KEY = os.environ.get("FAUCET_PRIVATE_KEY") -FAUCET_CHAIN_ID = os.getenv('FAUCET_CHAIN_ID') -FAUCET_CHAIN_NAME = get_chain_name(os.getenv('FAUCET_CHAIN_ID')) - -SQLALCHEMY_DATABASE_URI = os.getenv('FAUCET_DATABASE_URI') - -# env FAUCET_ENABLED_TOKENS -# sample JSON string: -# [ -# { -# "address": "0x19C653Da7c37c66208fbfbE8908A5051B57b4C70" -# "name": "GNO", -# "maximumAmount": 0.5 -# } -# ] -FAUCET_ENABLED_TOKENS = json.loads(os.getenv('FAUCET_ENABLED_TOKENS', default='[]')) +FAUCET_CHAIN_ID = int(os.getenv('FAUCET_CHAIN_ID')) +FAUCET_CHAIN_NAME = CHAIN_NAMES[FAUCET_CHAIN_ID] FAUCET_ADDRESS: LocalAccount = Account.from_key(FAUCET_PRIVATE_KEY).address FAUCET_RATE_LIMIT_STRATEGY = rate_limit_strategy FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS = int(os.getenv('FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS', 86400)) # 86400 = 24h +SQLALCHEMY_DATABASE_URI = os.getenv('FAUCET_DATABASE_URI') + CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '*') CAPTCHA_VERIFY_ENDPOINT = os.getenv('CAPTCHA_VERIFY_ENDPOINT') -CAPTCHA_SECRET_KEY = os.getenv('CAPTCHA_SECRET_KEY') \ No newline at end of file +CAPTCHA_SECRET_KEY = os.getenv('CAPTCHA_SECRET_KEY') diff --git a/api/api/utils.py b/api/api/utils.py index 2440279..1827d19 100644 --- a/api/api/utils.py +++ b/api/api/utils.py @@ -63,4 +63,4 @@ def is_amount_valid(amount, token_address, tokens_list): def generate_access_key(): access_key_id = secrets.token_hex(8) # returns a 16 chars long string secret_access_key = secrets.token_hex(16) # returns a 32 chars long string - return access_key_id, secret_access_key \ No newline at end of file + return access_key_id, secret_access_key diff --git a/api/migrations/README b/api/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/api/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/api/migrations/alembic.ini b/api/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/api/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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/api/migrations/env.py b/api/migrations/env.py new file mode 100644 index 0000000..0749ebf --- /dev/null +++ b/api/migrations/env.py @@ -0,0 +1,112 @@ +import logging +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +# 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. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +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=get_metadata(), literal_binds=True + ) + + 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. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/api/migrations/script.py.mako b/api/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/api/migrations/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/api/migrations/versions/022497197c7a_.py b/api/migrations/versions/022497197c7a_.py new file mode 100644 index 0000000..80dda97 --- /dev/null +++ b/api/migrations/versions/022497197c7a_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 022497197c7a +Revises: +Create Date: 2024-03-06 23:26:49.758798 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '022497197c7a' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('access_keys', + sa.Column('access_key_id', sa.String(length=16), nullable=False), + sa.Column('secret_access_key', sa.String(length=32), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('access_key_id') + ) + op.create_table('tokens', + sa.Column('name', sa.String(length=10), nullable=False), + sa.Column('chain_id', sa.Integer(), nullable=False), + sa.Column('address', sa.String(length=42), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('max_amount_day', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=6), nullable=False), + sa.PrimaryKeyConstraint('address') + ) + op.create_table('access_keys_config', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('erc20_max_amount_day', sa.Integer(), nullable=False), + sa.Column('native_max_amount_day', sa.Integer(), nullable=False), + sa.Column('access_key_id', sa.String(), nullable=True), + sa.Column('chain_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['access_key_id'], ['access_keys.access_key_id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('transactions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('hash', sa.String(length=32), nullable=False), + sa.Column('recipient', sa.String(length=42), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('token', sa.String(), nullable=True), + sa.Column('type', sa.String(length=10), nullable=False), + sa.Column('access_key_id', sa.String(), nullable=True), + sa.Column('requester_ip', sa.String(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['access_key_id'], ['access_keys.access_key_id'], ), + sa.ForeignKeyConstraint(['token'], ['tokens.address'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('transactions') + op.drop_table('access_keys_config') + op.drop_table('tokens') + op.drop_table('access_keys') + # ### end Alembic commands ### diff --git a/api/migrations/versions/71441c34724e_.py b/api/migrations/versions/71441c34724e_.py deleted file mode 100644 index 5360806..0000000 --- a/api/migrations/versions/71441c34724e_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: 71441c34724e -Revises: -Create Date: 2024-02-28 14:11:13.601403 - -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = '71441c34724e' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('access_keys', - sa.Column('access_key_id', sa.String(length=16), nullable=False), - sa.Column('secret_access_key', sa.String(length=32), nullable=False), - sa.Column('enabled', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('access_key_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('access_keys') - # ### end Alembic commands ### diff --git a/api/scripts/local_run_migrations.sh b/api/scripts/local_run_migrations.sh index daa87d0..445ada6 100644 --- a/api/scripts/local_run_migrations.sh +++ b/api/scripts/local_run_migrations.sh @@ -3,8 +3,8 @@ set -x # DB MIGRATIONS: -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///:memory python3 -m flask db init # only the first time we initialize the DB -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///:memory python3 -m flask db migrate +FLASK_APP=api FAUCET_ENABLED_CHAIN_IDS=10200 FAUCET_DATABASE_URI=sqlite:// python3 -m flask db init # only the first time we initialize the DB +FLASK_APP=api FAUCET_ENABLED_CHAIN_IDS=10200 FAUCET_DATABASE_URI=sqlite:// python3 -m flask db migrate # Reflect migrations into the database: # FLASK_APP=api python3 -m flask db upgrade diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 22420b4..9223b4e 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,12 +1,12 @@ import os import pytest -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, - ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, - NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) +from flask_migrate import upgrade +from temp_env_var import (FAUCET_ENABLED_TOKENS, NATIVE_TRANSFER_TX_HASH, + TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH) from api import create_app +from api.services.database import Token api_prefix = '/api/v1' @@ -30,8 +30,20 @@ def app(self, mocker): mocker = self._mock(mocker, TEMP_ENV_VARS) app = self._create_app() with app.app_context(): + upgrade() + self.populate_db() yield app @pytest.fixture def client(self, app): return app.test_client() + + def populate_db(self): + for enabled_token in FAUCET_ENABLED_TOKENS: + token = Token() + token.address = enabled_token['address'] + token.name = enabled_token['name'] + token.chain_id = enabled_token['chainId'] + token.max_amount_day = enabled_token['maximumAmount'] + token.type = enabled_token['type'] + token.save() diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index ca6e29c..e2446bf 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -1,29 +1,40 @@ -import json from secrets import token_bytes -from api.const import NATIVE_TOKEN_ADDRESS +from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, NATIVE_TOKEN_ADDRESS) ZERO_ADDRESS = "0x" + '0' * 40 -NATIVE_TOKEN_AMOUNT = 0.1 -ERC20_TOKEN_AMOUNT = 0.5 ERC20_TOKEN_ADDRESS = ZERO_ADDRESS CAPTCHA_TEST_SECRET_KEY = '0x0000000000000000000000000000000000000000' CAPTCHA_TEST_RESPONSE_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001' +FAUCET_CHAIN_ID = 10200 + FAUCET_ENABLED_TOKENS = [ - {"address": NATIVE_TOKEN_ADDRESS, "name": "Native", "maximumAmount": NATIVE_TOKEN_AMOUNT}, - {"address": ERC20_TOKEN_ADDRESS, "name": "TestToken", "maximumAmount": ERC20_TOKEN_AMOUNT} + { + "address": NATIVE_TOKEN_ADDRESS, + "name": "Native", + "maximumAmount": DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, + "chainId": FAUCET_CHAIN_ID, + "type": "native" + }, + { + "address": ERC20_TOKEN_ADDRESS, + "name": "TestToken", + "maximumAmount": DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + "chainId": FAUCET_CHAIN_ID, + "type": "erc20" + } ] TEMP_ENV_VARS = { 'FAUCET_RPC_URL': 'http://localhost:8545', - 'FAUCET_CHAIN_ID': '100000', + 'FAUCET_CHAIN_ID': str(FAUCET_CHAIN_ID), 'FAUCET_PRIVATE_KEY': token_bytes(32).hex(), 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '10', - 'FAUCET_ENABLED_TOKENS': json.dumps(FAUCET_ENABLED_TOKENS), - 'FAUCET_DATABASE_URI': 'sqlite:///', # run in-memory + 'FAUCET_DATABASE_URI': 'sqlite://', # run in-memory 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } diff --git a/api/tests/test_api.py b/api/tests/test_api.py index bb2502f..6cab36a 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,13 +1,16 @@ import pytest from conftest import BaseTest, api_prefix +from flask_migrate import upgrade # from mock import patch -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, - ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, - NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, +from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, + DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID, + NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH, TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) from api.services import Strategy -from api.services.database import AccessKey +from api.services.database import AccessKey, Transaction from api.utils import generate_access_key @@ -26,7 +29,7 @@ def test_ask_route_parameters(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': -1, - 'amount': NATIVE_TOKEN_AMOUNT, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) @@ -35,8 +38,8 @@ def test_ask_route_parameters(self, client): # wrong amount, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) @@ -45,8 +48,8 @@ def test_ask_route_parameters(self, client): # missing recipient, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 400 @@ -54,8 +57,8 @@ def test_ask_route_parameters(self, client): # wrong recipient recipient, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': 'not an address', 'tokenAddress': NATIVE_TOKEN_ADDRESS }) @@ -63,8 +66,8 @@ def test_ask_route_parameters(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': ERC20_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': '0x00000123', 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -73,8 +76,8 @@ def test_ask_route_parameters(self, client): # missing token address, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS }) assert response.status_code == 400 @@ -82,8 +85,8 @@ def test_ask_route_parameters(self, client): # wrong token address, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': 'non existing token address' }) @@ -92,12 +95,11 @@ def test_ask_route_parameters(self, client): def test_ask_route_native_transaction(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - print(response.get_json()) assert response.status_code == 200 assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH @@ -105,8 +107,8 @@ def test_ask_route_token_transaction(self, client): # not supported token, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': '0x' + '1' * 40 }) @@ -114,21 +116,25 @@ def test_ask_route_token_transaction(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) assert response.status_code == 200 assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH + transaction = Transaction.query.with_entities(Transaction.hash).filter_by(hash=TOKEN_TRANSFER_TX_HASH).first() + assert len(transaction) == 1 + assert transaction[0] == TOKEN_TRANSFER_TX_HASH + class TestCliAPI(BaseTest): def test_ask_route_parameters(self, client): access_key_id, secret_access_key = generate_access_key() http_headers = { - 'FAUCET_ACCESS_KEY_ID': access_key_id, - 'FAUCET_SECRET_ACCESS_KEY': secret_access_key + 'X-faucet-access-key-id': access_key_id, + 'X-faucet-secret-access-key': secret_access_key } response = client.post(api_prefix + '/cli/ask', json={}) @@ -136,8 +142,8 @@ def test_ask_route_parameters(self, client): assert response.status_code == 400 response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -148,8 +154,8 @@ def test_ask_route_parameters(self, client): AccessKey(access_key_id=access_key_id, secret_access_key=secret_access_key).save() response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -167,24 +173,27 @@ def app(self, mocker): mocker = self._mock(mocker, env_vars) app = self._create_app() - yield app + with app.app_context(): + upgrade() + self.populate_db() + yield app def test_ask_route_limit_by_ip(self, client): - # First request should return 200 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) assert response.status_code == 200 + assert response.status_code == 200 # Second request should return 429 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py deleted file mode 100644 index c5cd759..0000000 --- a/api/tests/test_cache.py +++ /dev/null @@ -1,15 +0,0 @@ -from datetime import datetime - -from api.services import Cache - - -def test_cache(): - address = '0x' + '0' * 40 - limit_seconds = 1 - cache = Cache(limit_seconds) - cache.clear() - cached = cache.limit_by_address(address) - assert cached is False - - cached = cache.limit_by_address(address) - assert isinstance(cached, datetime) diff --git a/api/tests/test_database.py b/api/tests/test_database.py index b70ff5e..5d3f645 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,15 +1,11 @@ from conftest import BaseTest -# from mock import patch -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, - ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, - NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) from api.services.database import AccessKey from api.utils import generate_access_key class TestDatabase(BaseTest): + def test_models(self, client): access_key_id, secret_access_key = generate_access_key() assert len(access_key_id) == 16