Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pre-filled form #27

Merged
merged 21 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading