From 2211adfbda314c4b0cdec4eb61de0ee3de0af5b1 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Sun, 30 May 2021 11:58:04 +0300 Subject: [PATCH] initial commit --- .dockerignore | 5 + .gitignore | 4 + Dockerfile | 20 ++++ README.md | 80 +++++++++++++ bridge-faucet.py | 231 +++++++++++++++++++++++++++++++++++++ docker-compose.yml.example | 19 +++ requirements.txt | 3 + 7 files changed, 362 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bridge-faucet.py create mode 100644 docker-compose.yml.example create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd8745d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +db +.env +docker-compose.yml +.git +.ipynb_checkpoints \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a8f5ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +db +.env +docker-compose.yml +.ipynb_checkpoints \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5126fc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.9-alpine + +RUN apk update && apk upgrade +# GCC. +RUN apk add --no-cache --virtual .build-deps gcc musl-dev + +RUN python -m pip install --upgrade pip +COPY requirements.txt . +RUN python -m pip install -r requirements.txt + +# Remove gcc. +RUN apk del .build-deps +# Remove cache. +RUN python -m pip cache purge + +WORKDIR /faucet + +COPY bridge-faucet.py . + +ENTRYPOINT python bridge-faucet.py \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2800735 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +OmniBridge faucet service +==== + +This service is monitoring transfers executed through the OmniBridge on the xDai chain and reward the tokens recipients with small amount of xdai. + +## Run by docker CLI + +1. Prepare `.env` file with the following (at least) variables definitons: + + ```bash + FAUCET_PRIVKEY=cafe...cafe + JSON_DB_DIR=/db + INITIAL_START_BLOCK=123 + ``` + + See below with the variables explanation. + +2. Create the directory where the faucet service will store its data. + + ```bash + mkdir ./db + ``` + +3. Run the service + + ```bash + docker run -ti --rm -v $(pwd)/db:/db --env-file .env omnibridge/bridge-faucet:latest + ``` + + _Note:_ the source mount point after the key `-v` is the directory created on the step 2. The destination mount point is the directory specified in the variable `JSON_DB_DIR`. + +## Run by docker-compose + +1. Create the directory where the faucet service will store its data. + + ```bash + mkdir ./db + ``` + +2. Initialize the `docker-compose.yml` file based on `docker-compose.yml.example`. Set proper values for the following variables (at least) there: `FAUCET_PRIVKEY`, `JSON_DB_DIR` and `INITIAL_START_BLOCK`. + + Make sure that the source mount point in the `volumes` section is the directory created on the step 1. + + See below with the variables explanation. + +3. Run the service + + ```bash + docker-compose up -d + ``` + +## Faucet configuration + +The following environment variables may be used to configure the faucet behavior: + +1. `XDAI_RPC` -- JSON RPC endpoint the faucet uses to monitor OB events and get data. **Default:** `https://xdai.poanetwork.dev`. + +2. `BSC_OB` -- an address of BSC-xDai OB mediator on the xDai side. **Default:** `0x59447362798334d3485c64D1e4870Fde2DDC0d75`. + +3. `ETH_OB` -- an address of ETH-xDai OB mediator on the xDai side. **Default:** `0xf6A78083ca3e2a662D6dd1703c939c8aCE2e268d`. + +4. `FAUCET_PRIVKEY` -- a private key of an account holding xdai to reward. **No default value!**. + +5. `GAS_PRICE` -- the gas price (in gwei) the faucet uses for reward transactions. **Default:** `1`. + +6. `GAS_LIMIT` -- the gas limit the faucet uses for reward transactions. **Default:** `30000`. + +7. `REWARD` -- amount of xdai used as reward. **Default:** `0.005`. + +8. `POLLING_INTERVAL` -- amount of time (in seconds) between two subsequent cycles to discover OB transfers and send rewards. **Default:** `60`. + +9. `INITIAL_START_BLOCK` -- a block the first faucet's attempt to discover OB transfers starts from. **No default value!**. + +10. `FINALIZATION_INTERVAL` -- a number of blocks starting from the chain head to consider the chain as finalized. **Default:** `12`. + +11. `JSON_DB_DIR` -- a directory where the faucet service keeps its data. **No default value!**. + +12. `JSON_START_BLOCK` -- a name of JSON file where the last observed block is stored. **Default:** `faucet_start_block.json`. + +13. `JSON_CONTRACTS` -- a name of JSON file where addresses of recipient-contracts are stored. **Default:** `xdai-contracts.json`. \ No newline at end of file diff --git a/bridge-faucet.py b/bridge-faucet.py new file mode 100644 index 0000000..2eb3cfb --- /dev/null +++ b/bridge-faucet.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 + +from json import load, dump +from web3 import Web3, HTTPProvider +from eth_account import Account +from time import sleep +from os import getenv +from dotenv import load_dotenv +from logging import basicConfig, info, INFO + +basicConfig(level=INFO) + +STOP_FILE = 'stop.tmp' + +dotenv_read = False + +while True: + XDAI_RPC = getenv('XDAI_RPC', 'https://xdai.poanetwork.dev') + + BSC_OB = getenv('BSC_OB', '0x59447362798334d3485c64D1e4870Fde2DDC0d75') + ETH_OB = getenv('ETH_OB', '0xf6A78083ca3e2a662D6dd1703c939c8aCE2e268d') + + FAUCET_PRIVKEY = getenv('FAUCET_PRIVKEY', None) + + GAS_PRICE = int(getenv('GAS_PRICE', 1)) + GAS_LIMIT = int(getenv('GAS_LIMIT', 30000)) + REWARD = float(getenv('REWARD', 0.005)) + POLLING_INTERVAL = getenv('POLLING_INTERVAL', 60) + + #INITIAL_START_BLOCK = 16173518 + INITIAL_START_BLOCK = int(getenv('INITIAL_START_BLOCK', 16190379)) + FINALIZATION_INTERVAL = int(getenv('FINALIZATION_INTERVAL', 12)) # blocks + + JSON_DB_DIR = getenv('JSON_DB_DIR', '.') + JSON_START_BLOCK = getenv('JSON_START_BLOCK', 'faucet_start_block.json') + JSON_CONTRACTS = getenv('JSON_CONTRACTS', 'xdai-contracts.json') + + if not FAUCET_PRIVKEY: + if dotenv_read: + break + + info('Environment is not configured') + load_dotenv('./.env') + dotenv_read = True + else: + break + +if not FAUCET_PRIVKEY: + raise BaseException("Faucet's privkey is not provided. Check the configuration") + +info(f'XDAI_RPC = {XDAI_RPC}') +info(f'BSC_OB = {BSC_OB}') +info(f'ETH_OB = {ETH_OB}') +info(f'FAUCET_PRIVKEY = ...') +info(f'GAS_PRICE = {GAS_PRICE}') +info(f'GAS_LIMIT = {GAS_LIMIT}') +info(f'REWARD = {REWARD}') +info(f'POLLING_INTERVAL = {POLLING_INTERVAL}') +info(f'INITIAL_START_BLOCK = {INITIAL_START_BLOCK}') +info(f'FINALIZATION_INTERVAL = {FINALIZATION_INTERVAL}') +info(f'JSON_DB_DIR = {JSON_DB_DIR}') +info(f'JSON_START_BLOCK = {JSON_START_BLOCK}') +info(f'JSON_CONTRACTS = {JSON_CONTRACTS}') + +# event +# TokensBridged(address token, address recipient, uint256 value, bytes32 messageId) +ABI = """ +[ + { + "type":"event", + "name":"TokensBridged", + "inputs":[ + { + "type":"address", + "name":"token", + "internalType":"address", + "indexed":true + }, + { + "type":"address", + "name":"recipient", + "internalType":"address", + "indexed":true + }, + { + "type":"uint256", + "name":"value", + "internalType":"uint256", + "indexed":false + }, + { + "type":"bytes32", + "name":"messageId", + "internalType":"bytes32", + "indexed":true + } + ], + "anonymous":false + } +] +""" + +xdai_w3 = Web3(HTTPProvider(XDAI_RPC)) +bsc_ob = xdai_w3.eth.contract(abi = ABI, address = BSC_OB) +eth_ob = xdai_w3.eth.contract(abi = ABI, address = ETH_OB) + +faucet = Account.privateKeyToAccount(FAUCET_PRIVKEY) + +try: + with open(f'{JSON_DB_DIR}/{JSON_START_BLOCK}') as f: + tmp = load(f) + start_block = int(tmp['start_block']) +except IOError: + info("no start block stored previously") + start_block = INITIAL_START_BLOCK +info(f'start block: {start_block}') + +while True: + try: + with open(f'{JSON_DB_DIR}/{STOP_FILE}') as f: + info("Stopping faucet") + break + except IOError: + pass + + try: + last_block = xdai_w3.eth.getBlock('latest').number + except: + raise BaseException('Cannot get the latest block number') + info(f'current last block: {last_block}') + last_block = last_block - FINALIZATION_INTERVAL + + filter = bsc_ob.events.TokensBridged.build_filter() + info(f'Looking for TokensBridged events on BSC-xDAI OB from {start_block} to {last_block}') + try: + bsc_logs = xdai_w3.eth.getLogs({'fromBlock': start_block, + 'toBlock': last_block, + 'address': filter.address, + 'topics': filter.topics}) + except: + raise BaseException('Cannot get BSC-xDAI OB OB logs') + info(f'Found {len(bsc_logs)} TokensBridged events on BSC-xDAI OB') + + filter = eth_ob.events.TokensBridged.build_filter() + info(f'Looking for TokensBridged events on ETH-xDAI OB from {start_block} to {last_block}') + try: + eth_logs = xdai_w3.eth.getLogs({'fromBlock': start_block, + 'toBlock': last_block, + 'address': filter.address, + 'topics': filter.topics}) + except: + raise BaseException('Cannot get ETH-xDAI OB OB logs') + info(f'Found {len(eth_logs)} TokensBridged events on ETH-xDAI OB') + + recipients = set() + + for log in bsc_logs: + recipient = bsc_ob.events.TokensBridged().processLog(log).args.recipient + recipients.add(recipient) + info(f'Identified {len(recipients)} tokens recipients from BSC-xDAI OB events') + + tmp = len(recipients) + for log in eth_logs: + recipient = eth_ob.events.TokensBridged().processLog(log).args.recipient + recipients.add(recipient) + info(f'Identified {len(recipients) - tmp} tokens recipients from ETH-xDAI OB events') + + try: + with open(f'{JSON_DB_DIR}/{JSON_CONTRACTS}') as f: + contracts = load(f) + except IOError: + info("no contracts identified previously") + contracts = {} + + endowing = [] + for recipient in recipients: + if recipient in contracts: + continue + code = xdai_w3.eth.getCode(recipient) + if code != b'': + contracts[recipient] = True + continue + balance = xdai_w3.eth.getBalance(recipient) + if balance == 0: + info(f'{recipient} balance is zero') + endowing.append(recipient) + info(f'found {len(endowing)} accounts for reward') + + with open(f'{JSON_DB_DIR}/{JSON_CONTRACTS}', 'w') as json_file: + dump(contracts, json_file) + + balance_error = False + + if len(endowing) > 0: + try: + faucet_balance = xdai_w3.eth.getBalance(faucet.address) + except: + raise BaseException("Cannot get faucet balance") + info(f'faucet balance: {faucet_balance}') + + if faucet_balance > len(endowing) * GAS_LIMIT * Web3.toWei(GAS_PRICE, 'gwei'): + try: + nonce = xdai_w3.eth.getTransactionCount(faucet.address) + except: + raise BaseException("Cannot get transactions count of faucet's account") + info(f'starting nonce: {nonce}') + for recipient in endowing: + tx = { + 'nonce': nonce, + 'gas': GAS_LIMIT, + 'gasPrice': Web3.toWei(GAS_PRICE, 'gwei'), + 'data': b'Rewarded for OmniBridge transaction', + 'chainId': 100, + 'value': Web3.toWei(REWARD, 'ether'), + 'to': recipient, + } + rawtx = faucet.signTransaction(tx) + sent_tx_hash = xdai_w3.eth.sendRawTransaction(rawtx.rawTransaction) + info(f'{recipient} rewarded by {Web3.toHex(sent_tx_hash)}') + nonce += 1 + sleep(0.1) + else: + info(f'not enough balance on the faucet {faucet.address}') + balance_error = True + + if not balance_error: + start_block = last_block + 1 + with open(f'{JSON_DB_DIR}/{JSON_START_BLOCK}', 'w') as json_file: + dump({'start_block': start_block}, json_file) + + sleep(POLLING_INTERVAL) \ No newline at end of file diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..d4bfeeb --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,19 @@ +version: "3.9" + +services: + faucet: + image: omnibridge/bridge-faucet:latest + container_name: bridge-faucet + environment: + # faucet account's private key must be here (without 0x) + - FAUCET_PRIVKEY=cafe...cafe + - JSON_DB_DIR=/db + - INITIAL_START_BLOCK=123 + volumes: + - ./db:/db + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "1" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0503e68 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +eth-account==0.5.4 +web3==5.17.0 +python-dotenv==0.17.1 \ No newline at end of file