From d3416f42ab5960fa6dbf7009d8006835fd7d386e Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Thu, 30 Nov 2023 14:37:27 +0100 Subject: [PATCH] API: Add .env.example, improve API, add capacity to handle multiple tokens<>amounts. App: adapt app to latest API changes --- .github/workflows/publish-api.yaml | 23 +++++++ api/.env.example | 8 +++ api/api/api.py | 85 ++++++++++++++++++------- api/api/const.py | 1 + api/api/settings.py | 14 +++- api/api/utils.py | 8 +++ api/tests/temp_env_var.py | 15 ++++- api/tests/test_api.py | 40 ++++++------ app/src/components/hcaptcha/hcaptcha.js | 72 +++++++++++++-------- app/src/css/App.css | 6 ++ 10 files changed, 195 insertions(+), 77 deletions(-) create mode 100644 api/.env.example create mode 100644 api/api/const.py create mode 100644 api/api/utils.py diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index facb1ad..96af847 100644 --- a/.github/workflows/publish-api.yaml +++ b/.github/workflows/publish-api.yaml @@ -18,8 +18,31 @@ env: # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install dependencies + working-directory: ./api + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Test with pytest + working-directory: ./api + run: | + python3 -m pytest -s + build-and-push-image: runs-on: ubuntu-latest + needs: test # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. permissions: contents: read diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..a444063 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,8 @@ +FAUCET_AMOUNT=0.1 +FAUCET_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 +FAUCET_RPC_URL=https://rpc.chiadochain.net +FAUCET_CHAIN_ID=10200 +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/api/api.py b/api/api/api.py index 2dd0219..2d61d13 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -6,6 +6,7 @@ from web3.middleware import construct_sign_and_send_raw_middleware from .services import Token, Cache, Strategy, claim_native, claim_token, captcha_verify +from .const import NATIVE_TOKEN_ADDRESS def is_token_enabled(address, tokens_list): @@ -14,18 +15,62 @@ def is_token_enabled(address, tokens_list): is_enabled = False checksum_address = Web3.to_checksum_address(address) - for enabled_tokens in tokens_list: - if checksum_address == enabled_tokens['address']: + for enabled_token in tokens_list: + if checksum_address == enabled_token['address']: is_enabled = True break return is_enabled +def is_amount_valid(amount, token_address, tokens_list): + + if not token_address: + raise ValueError( + 'Token address not supported', + str(token_address), + 'supported tokens', + " ".join(list(map(lambda x: x['address'], tokens_list))) + ) + + token_address_to_check = None + if token_address.lower() == NATIVE_TOKEN_ADDRESS: + token_address_to_check = NATIVE_TOKEN_ADDRESS + else: + token_address_to_check = Web3.to_checksum_address(token_address) + + for enabled_token in tokens_list: + if token_address_to_check == enabled_token['address']: + return ( + amount <= enabled_token['maximumAmount'], + enabled_token['maximumAmount'] + ) + + raise ValueError( + 'Token address not supported', + token_address, + 'supported tokens', + " ".join(list(map(lambda x: x['address'], tokens_list))) + ) + def get_balance(w3, address, format='ether'): balance = w3.eth.get_balance(address) return w3.from_wei(balance, format) +def setup_logger(log_level): + # Set logger + logging.basicConfig(level=log_level) + + +def print_info(w3, config): + faucet_native_balance = get_balance(w3, config['FAUCET_ADDRESS']) + logging.info("="*60) + logging.info("RPC_URL = " + config['FAUCET_RPC_URL']) + logging.info("FAUCET ADDRESS = " + config['FAUCET_ADDRESS']) + logging.info("FAUCET BALANCE = %d %s" % (faucet_native_balance, config['FAUCET_CHAIN_NAME'])) + logging.info("="*60) + + def create_app(): # Init Flask app app = Flask(__name__) @@ -38,16 +83,9 @@ def create_app(): w3.middleware_onion.add(construct_sign_and_send_raw_middleware(app.config['FAUCET_PRIVATE_KEY'])) cache = Cache(app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']) - faucet_balance = get_balance(w3, app.config['FAUCET_ADDRESS']) - - # Set logger - logging.basicConfig(level=logging.INFO) - logging.info("="*60) - logging.info("RPC_URL = " + app.config['FAUCET_RPC_URL']) - logging.info("FAUCET ADDRESS = " + app.config['FAUCET_ADDRESS']) - logging.info("FAUCET BALANCE = %d %s" % (faucet_balance, app.config['FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL'])) - logging.info("="*60) + setup_logger(logging.INFO) + print_info(w3, app.config) @apiv1.route("/status") def status(): @@ -56,18 +94,11 @@ def status(): @apiv1.route("/info") def info(): - enabled_tokens = [] - enabled_tokens.extend(app.config['FAUCET_ENABLED_TOKENS']) - enabled_tokens.append( - { - 'name': app.config['FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL'], - 'address': 'native' - } - ) return jsonify( - enabledTokens=enabled_tokens, - maximumAmount=app.config['FAUCET_AMOUNT'], - chainId=app.config['FAUCET_CHAIN_ID'] + enabledTokens=app.config['FAUCET_ENABLED_TOKENS'], + chainId=app.config['FAUCET_CHAIN_ID'], + chainName=app.config['FAUCET_CHAIN_NAME'], + faucetAddress=app.config['FAUCET_ADDRESS'] ), 200 @@ -103,8 +134,14 @@ def ask(): validation_errors.append('tokenAddress: invalid token address'), 400 amount = request_data.get('amount', None) - if not amount or float(amount) > app.config['FAUCET_AMOUNT']: - validation_errors.append('amount: a valid amount must be specified and must be less or equals to %s' % app.config['FAUCET_AMOUNT']) + + try: + amount_valid, amount_limit = is_amount_valid(amount, token_address, 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) if len(validation_errors) > 0: return jsonify(errors=validation_errors), 400 diff --git a/api/api/const.py b/api/api/const.py new file mode 100644 index 0000000..80bd844 --- /dev/null +++ b/api/api/const.py @@ -0,0 +1 @@ +NATIVE_TOKEN_ADDRESS='native' \ No newline at end of file diff --git a/api/api/settings.py b/api/api/settings.py index d8fb8c3..9dc7847 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -2,6 +2,7 @@ import json from .services import RateLimitStrategy +from .utils import get_chain_name from dotenv import load_dotenv from eth_account import Account @@ -16,9 +17,18 @@ 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_NATIVE_TOKEN_SYMBOL=os.getenv('FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL', default='xDAI') +FAUCET_CHAIN_NAME=get_chain_name(os.getenv('FAUCET_CHAIN_ID')) + +# 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_AMOUNT=float(os.getenv('FAUCET_AMOUNT')) FAUCET_ADDRESS: LocalAccount = Account.from_key(FAUCET_PRIVATE_KEY).address FAUCET_RATE_LIMIT_STRATEGY=rate_limit_strategy FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS=seconds=os.getenv('FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS', 86400) # 86400 = 24h diff --git a/api/api/utils.py b/api/api/utils.py new file mode 100644 index 0000000..da1bfba --- /dev/null +++ b/api/api/utils.py @@ -0,0 +1,8 @@ +def get_chain_name(chain_id): + chains = { + 1: 'Ethereum', + 100: 'Gnosis Chain', + 10200: 'Chiado Chain Testnet' + } + + return chains.get(int(chain_id), 'Undefined') \ No newline at end of file diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index 3478432..468d1aa 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -1,18 +1,27 @@ from secrets import token_bytes -import json + +from api.const import 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_ENABLED_TOKENS = [ + {"address": NATIVE_TOKEN_ADDRESS, "name": "Native", "maximumAmount": NATIVE_TOKEN_AMOUNT}, + {"address": ERC20_TOKEN_ADDRESS, "name": "TestToken", "maximumAmount": ERC20_TOKEN_AMOUNT} +] + TEMP_ENV_VARS = { 'FAUCET_RPC_URL': 'http://localhost:8545', 'FAUCET_CHAIN_ID': '100000', 'FAUCET_PRIVATE_KEY': token_bytes(32).hex(), - 'FAUCET_AMOUNT': 0.1, 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '1', - 'FAUCET_ENABLED_TOKENS': json.loads('[{"address":"' + ZERO_ADDRESS + '", "name": "TestToken"}]'), + 'FAUCET_ENABLED_TOKENS': FAUCET_ENABLED_TOKENS, 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 26502c0..1861955 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -4,7 +4,7 @@ import pytest from conftest import api_prefix # from mock import patch -from temp_env_var import TEMP_ENV_VARS, NATIVE_TRANSFER_TX_HASH, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS, CAPTCHA_TEST_RESPONSE_TOKEN +from temp_env_var import TEMP_ENV_VARS, NATIVE_TRANSFER_TX_HASH, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS, CAPTCHA_TEST_RESPONSE_TOKEN, NATIVE_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, ERC20_TOKEN_ADDRESS class BaseTest: @@ -47,9 +47,9 @@ def test_ask_route_parameters(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': -1, - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'amount': NATIVE_TOKEN_AMOUNT, 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'native' + 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 400 @@ -57,9 +57,9 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'amount': NATIVE_TOKEN_AMOUNT + 1, 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'native' + 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 400 @@ -67,8 +67,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': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, - 'tokenAddress': 'native' + 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 400 @@ -76,9 +76,9 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'amount': NATIVE_TOKEN_AMOUNT + 1, 'recipient': 'not an address', - 'tokenAddress': 'native' + 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 400 @@ -86,7 +86,7 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'amount': NATIVE_TOKEN_AMOUNT + 1, 'recipient': ZERO_ADDRESS }) assert response.status_code == 400 @@ -95,7 +95,7 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'amount': NATIVE_TOKEN_AMOUNT + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': 'non existing token address' }) @@ -105,9 +105,9 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'amount': NATIVE_TOKEN_AMOUNT, 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'native' + 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 200 assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH @@ -117,7 +117,7 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'amount': NATIVE_TOKEN_AMOUNT, 'recipient': ZERO_ADDRESS, 'tokenAddress': '0x' + '1'*40 }) @@ -126,9 +126,9 @@ 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': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'amount': NATIVE_TOKEN_AMOUNT, 'recipient': ZERO_ADDRESS, - 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] + 'tokenAddress': ERC20_TOKEN_ADDRESS }) assert response.status_code == 200 assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH @@ -154,9 +154,9 @@ def test_ask_route_limit_by_ip(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'amount': NATIVE_TOKEN_AMOUNT, 'recipient': ZERO_ADDRESS, - 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] + 'tokenAddress': ERC20_TOKEN_ADDRESS }) assert response.status_code == 200 @@ -164,8 +164,8 @@ def test_ask_route_limit_by_ip(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'amount': NATIVE_TOKEN_AMOUNT, 'recipient': ZERO_ADDRESS, - 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] + 'tokenAddress': ERC20_TOKEN_ADDRESS }) assert response.status_code == 429 \ No newline at end of file diff --git a/app/src/components/hcaptcha/hcaptcha.js b/app/src/components/hcaptcha/hcaptcha.js index 791ac3f..dce38ae 100644 --- a/app/src/components/hcaptcha/hcaptcha.js +++ b/app/src/components/hcaptcha/hcaptcha.js @@ -10,7 +10,8 @@ import { Select, MenuItem, useMediaQuery, - InputLabel + FormControl, + FormLabel } from "@mui/material"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; @@ -28,6 +29,7 @@ export const HCaptchaForm = function () { const [captchaVerified, setCaptchaVerified] = useState(false); const [captchaToken, setCaptchaToken] = useState(""); const [chainId, setChainId] = useState(null); + const [chainName, setChainName] = useState(""); const [walletAddress, setWalletAddress] = useState(""); const [tokenAddress, setTokenAddress] = useState(""); const [enabledTokens, setEnabledTokens] = useState([]); @@ -42,8 +44,8 @@ export const HCaptchaForm = function () { getFaucetInfo() .then((response) => { setChainId(response.data.chainId); + setChainName(response.data.chainName); setEnabledTokens(response.data.enabledTokens); - setTokenAmount(response.data.maximumAmount); }) .catch((error) => { toast.error(error); @@ -56,6 +58,14 @@ export const HCaptchaForm = function () { const handleTokenChange = (event) => { setTokenAddress(event.target.value); + + // Set token amount + for (let idx in enabledTokens) { + if (enabledTokens[idx].address.toLowerCase() == event.target.value.toLowerCase()) { + setTokenAmount(enabledTokens[idx].maximumAmount) + break; + } + } }; const isTabletOrMobile = useMediaQuery("(max-width:960px)"); @@ -157,7 +167,7 @@ export const HCaptchaForm = function () { component="h2" align="center" gutterBottom={true}> - Gnosis Faucet + {chainName} Faucet - Wallet address - + + Wallet address + + - Token - + + Choose token + + - + + + {showCaptcha()} diff --git a/app/src/css/App.css b/app/src/css/App.css index b719f39..889040f 100644 --- a/app/src/css/App.css +++ b/app/src/css/App.css @@ -5,3 +5,9 @@ left: 10px; top: 15px; } + + +.Toastify__toast { + font-size: 16px; + font-family: Roboto, Helvetica, Arial, sans-serif; +} \ No newline at end of file