Skip to content

Commit

Permalink
Merge pull request #27 from gnosischain/dev
Browse files Browse the repository at this point in the history
Pre-filled form
  • Loading branch information
ilge-ustun authored Mar 11, 2024
2 parents 2243ef5 + 54f51fa commit f7d153b
Show file tree
Hide file tree
Showing 23 changed files with 761 additions and 332 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/publish-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
- name: Run tests
working-directory: ./api
run: |
python3 -m pytest -s
python3 -m unittest discover -p 'test_*.py'
build-and-push-image:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ python3 -m flask --app api run --port 8000

```
cd api
python3 -m pytest -s
python3 -m unittest discover tests
```

### Run Flake8 and isort
Expand Down
8 changes: 7 additions & 1 deletion api/api/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from enum import Enum

NATIVE_TOKEN_ADDRESS = 'native'
ZERO_ADDRESS = "0x" + '0' * 40
NATIVE_TOKEN_ADDRESS = ZERO_ADDRESS
DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY = 0.01
DEFAULT_ERC20_MAX_AMOUNT_PER_DAY = 0.01

Expand All @@ -14,3 +15,8 @@
class FaucetRequestType(Enum):
web = 'web'
cli = 'cli'


class TokenType(Enum):
native = 'native'
erc20 = 'erc20'
130 changes: 25 additions & 105 deletions api/api/routes.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
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,
from .const import FaucetRequestType, TokenType
from .services import (AskEndpointValidator, Web3Singleton, claim_native,
claim_token)
from .services.database import AccessKey, Token, Transaction

Expand Down Expand Up @@ -35,69 +33,6 @@ def info():
), 200


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']
)

if not catpcha_verified:
validation_errors.append('captcha: validation failed')

if request_data.get('chainId') != current_app.config['FAUCET_CHAIN_ID']:
validation_errors.append('chainId: %s is not supported. Supported chainId: %s' % (request_data.get('chainId'), current_app.config['FAUCET_CHAIN_ID']))

recipient = request_data.get('recipient', None)
if not Web3.is_address(recipient):
validation_errors.append('recipient: A valid recipient address must be specified')

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 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


def _ask(request_data, validate_captcha=True, access_key=None):
"""Process /ask request
Expand All @@ -106,64 +41,49 @@ def _ask(request_data, validate_captcha=True, access_key=None):
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:
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

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 by recipient
transaction = Transaction.last_by_recipient(recipient)
elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip.value:
# 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 = 24-(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
validator = AskEndpointValidator(request_data,
validate_captcha,
access_key=access_key)
ok = validator.validate()
if not ok:
return jsonify(message=validator.errors), validator.http_return_code

# convert amount to wei format
amount_wei = Web3.to_wei(amount, 'ether')
amount_wei = Web3.to_wei(validator.amount, 'ether')
try:
# convert recipient address to checksum address
recipient = Web3.to_checksum_address(recipient)
recipient = Web3.to_checksum_address(validator.recipient)

w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'], current_app.config['FAUCET_PRIVATE_KEY'])
w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'],
current_app.config['FAUCET_PRIVATE_KEY'])

token = Token.get_by_address(token_address)
if token.type == 'native':
tx_hash = claim_native(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei)
if validator.token.type == TokenType.native.value:
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)

tx_hash = claim_token(w3, current_app.config['FAUCET_ADDRESS'],
recipient,
amount_wei,
validator.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
transaction.amount = validator.amount
transaction.token = validator.token.address
transaction.requester_ip = validator.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])
Expand Down
2 changes: 1 addition & 1 deletion api/api/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .captcha import captcha_verify
from .database import DatabaseSingleton
from .rate_limit import RateLimitStrategy, Strategy
from .token import Token
from .transaction import Web3Singleton, claim_native, claim_token
from .validator import AskEndpointValidator
74 changes: 68 additions & 6 deletions api/api/services/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@
from datetime import datetime

from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData

from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY,
DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType)

db = SQLAlchemy()
flask_db_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"
}
db_metadata = MetaData(naming_convention=flask_db_convention)
db = SQLAlchemy(metadata=db_metadata)


class Database:
Expand Down Expand Up @@ -80,10 +89,15 @@ class Token(BaseModel):
@classmethod
def enabled_tokens(cls):
return cls.query.filter_by(enabled=True).all()

@classmethod
def get_by_address(cls, address):
return cls.query.filter_by(address=address).first()

@classmethod
def get_by_address_and_chain_id(cls, address, chain_id):
return cls.query.filter_by(address=address,
chain_id=chain_id).first()


class AccessKey(BaseModel):
Expand All @@ -92,10 +106,17 @@ class AccessKey(BaseModel):
enabled = db.Column(db.Boolean, default=True, nullable=False)

__tablename__ = "access_keys"
__table_args__ = (
db.UniqueConstraint('secret_access_key'),
)

def __repr__(self):
return f"<Access Key {self.access_key_id}>"

@classmethod
def get_by_key_id(cls, access_key_id):
return cls.query.filter_by(access_key_id=access_key_id).first()


class AccessKeyConfig(BaseModel):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
Expand All @@ -105,10 +126,15 @@ class AccessKeyConfig(BaseModel):
chain_id = db.Column(db.Integer, nullable=False)

__tablename__ = "access_keys_config"
__table_args__ = tuple(
db.PrimaryKeyConstraint('access_key_id', 'chain_id')
__table_args__ = (
db.UniqueConstraint('access_key_id', 'chain_id'),
)

@classmethod
def get_by_key_id_and_chain_id(cls, access_key_id, chain_id):
return cls.query.filter_by(access_key_id=access_key_id,
chain_id=chain_id).first()


class Transaction(BaseModel):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
Expand All @@ -123,8 +149,8 @@ class Transaction(BaseModel):
updated = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

__tablename__ = "transactions"
__table_args__ = tuple(
db.PrimaryKeyConstraint('hash', 'token')
__table_args__ = (
db.UniqueConstraint('hash'),
)

@classmethod
Expand All @@ -134,3 +160,39 @@ def last_by_recipient(cls, recipient):
@classmethod
def last_by_ip(cls, ip):
return cls.query.filter_by(requester_ip=ip).order_by(cls.created.desc()).first()

@classmethod
def last_by_ip_and_recipient(cls, ip, recipient):
return cls.query.filter_by(requester_ip=ip, recipient=recipient).order_by(cls.created.desc()).first()

@classmethod
def last_by_ip_or_recipient(cls, ip, recipient):
return cls.query.filter(
((cls.requester_ip == ip) | (cls.recipient == recipient))
).order_by(cls.created.desc()).first()

@classmethod
def get_by_hash(cls, hash):
return cls.query.filter_by(hash=hash).first()

@classmethod
def get_amount_sum_by_access_key_and_token(cls,
access_key_id,
token_address,
custom_timerange=None):
if custom_timerange:
return cls.query.with_entities(
db.func.sum(cls.amount).label('amount')
).filter_by(
access_key_id=access_key_id,
token=token_address,
).filter(
cls.created >= custom_timerange
).first().amount
else:
return cls.query.with_entities(
db.func.sum(cls.amount).label('amount')
).filter_by(
access_key_id=access_key_id,
token=token_address
).first().amount
8 changes: 7 additions & 1 deletion api/api/services/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ class Strategy(Enum):
ip = 'IP'
address = 'ADDRESS'
ip_and_address = 'IP_AND_ADDRESS'
ip_or_address = 'IP_OR_ADDRESS'


class RateLimitStrategy:
_strategies = set([Strategy.ip.value, Strategy.address.value, Strategy.ip_and_address.value])
_strategies = set([
Strategy.ip.value,
Strategy.address.value,
Strategy.ip_and_address.value,
Strategy.ip_or_address.value
])
_strategy = None
_default_strategy = Strategy.address.value

Expand Down
Loading

0 comments on commit f7d153b

Please sign in to comment.