diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70e53ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv +__pycache__ +config.json +state.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a14542e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: master + hooks: + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: fix-encoding-pragma + args: ['--remove'] + - id: requirements-txt-fixer + - id: trailing-whitespace + - id: check-json + - id: check-yaml + - repo: https://gitlab.com/pycqa/flake8 + rev: master + hooks: + - id: flake8 + args: ['--max-line-length=120'] + - repo: https://github.com/FalconSocial/pre-commit-python-sorter + rev: master + hooks: + - id: python-import-sorter + args: ['--silent-overwrite'] + - repo: https://github.com/chewse/pre-commit-mirrors-pydocstyle + rev: master + hooks: + - id: pydocstyle + args: ['--config=.pydocstyle', '--match="(?!test_).*\.py"'] diff --git a/.pydocstyle b/.pydocstyle new file mode 100644 index 0000000..aef2483 --- /dev/null +++ b/.pydocstyle @@ -0,0 +1,2 @@ +[pydocstyle] +ignore = D100,D104,D400,D203,D204,D101,D213,D202 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3629d9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.8-slim +RUN apt-get update \ + && apt-get install -y git \ + && apt-get autoclean \ + && pip install pre-commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..22eb01b --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# flexpool-activity + +[Flexpool.io](https://flexpool.io) is a next-generation [Ethereum](https://ethereum.org/en/) mining pool known for their +[#STOPEIP1559](https://stopeip1559.org/) move. `flexpool-activity` is able to listen and notify when a new **block** is +mined by the pool and display the up-to-date **miner balance** and convert it to **fiat**. + +## Installation + +``` +sudo apt install python3-virtualenv +virtualenv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Telegram bot + +This [tutorial](https://takersplace.de/2019/12/19/telegram-notifications-with-nagios/) explains how to create a Telegram +bot. You'll need the `chat_id` and `auth_key` for the next section. + +## Configuration + +Configuration file use the JSON format with the following keys: +* `miner`: wallet address of the miner +* `currency`: symbol of the currency to convert (default: USD) +* `telegram`: send notifications with Telegram (optional) + * `auth_key`: Telegram authentication key for the bot API + * `chat_id`: Telegram chat room id (where to send the message) +* `state_file`: persist data between runs into this file (default: `state.json`) + +See [configuration example](config.example.json). + + +## Usage + +``` +python3 main --help +``` + + +## Contribute + +Contributions are welcomed! Feel free to update the code and create a pull-request. + +Be sure to lint the code before: + +``` +docker build -t pre-commit . +docker run -it -v $(pwd):/mnt/ --rm pre-commit bash +# cd /mnt/ +# pre-commit run --all-files +# exit +``` diff --git a/coingecko.py b/coingecko.py new file mode 100644 index 0000000..2d45cc7 --- /dev/null +++ b/coingecko.py @@ -0,0 +1,14 @@ +import logging + +import requests + +logger = logging.getLogger(__name__) + + +def get_rate(ids, vs_currencies): + logger.debug(f'getting {ids} price in {vs_currencies} on coingecko') + url = f'https://api.coingecko.com/api/v3/simple/price?ids={ids}&vs_currencies={vs_currencies}' + r = requests.get(url) + r.raise_for_status() + logger.debug(r.json()) + return r.json()[ids.lower()][vs_currencies.lower()] diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..dcbdf69 --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "miner": "string", + "currency": "USD", + "telegram": { + "chat_id": 123, + "auth_key": "string" + }, + "state_file": "state.json" +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..1c8a8cb --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ +import json +import os + +from jsonschema import validate + +absolute_path = os.path.split(os.path.abspath(__file__))[0] + + +class InvalidConfigException(Exception): + pass + + +def read_config(filename=None): + if filename and os.path.isfile(filename): + with open(filename, 'r') as fd: + return json.load(fd) + else: + return {} + + +def validate_config(config): + if config is None: + raise InvalidConfigException('config is not a dict') + with open(os.path.join(absolute_path, 'config.schema.json'), 'r') as fd: + schema = json.loads(fd.read()) + validate(instance=config, schema=schema) diff --git a/config.schema.json b/config.schema.json new file mode 100644 index 0000000..2a37ead --- /dev/null +++ b/config.schema.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "telegram": { + "type": "object", + "properties": { + "chat_id": { + "type": "number" + }, + "auth_key": { + "type": "string" + } + }, + "required": [ + "chat_id", + "auth_key" + ] + }, + "miner": { + "type": "string" + }, + "state_file": { + "type": "string" + } + }, + "required": [ + "miner" + ] +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..3ebd28b --- /dev/null +++ b/main.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +import argparse +import logging +import os + +import flexpoolapi +import telegram +from coingecko import get_rate +from config import read_config, validate_config +from flexpoolapi.utils import format_weis +from humanfriendly import format_timespan +from requests.exceptions import HTTPError +from state import read_state, write_state + +logger = logging.getLogger(__name__) + + +def parse_arguments(): + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--verbose', dest='loglevel', action='store_const', const=logging.INFO, + help='print more output') + parser.add_argument('-d', '--debug', dest='loglevel', action='store_const', const=logging.DEBUG, + default=logging.WARNING, help='print even more output') + parser.add_argument('-o', '--logfile', help='logging file location') + parser.add_argument('-N', '--disable-notifications', dest='disable_notifications', action='store_true', + help='do not send notifications') + parser.add_argument('-c', '--config', help='configuration file name', default='config.json') + args = parser.parse_args() + return args + + +def setup_logging(args): + log_format = '%(asctime)s %(levelname)s: %(message)s' if args.logfile else '%(levelname)s: %(message)s' + logging.basicConfig(format=log_format, level=args.loglevel, filename=args.logfile) + + +def convert_weis(weis, prec=5): + return round(weis / 10**18, prec) + + +def main(): + args = parse_arguments() + setup_logging(args) + + config = read_config(args.config) + validate_config(config) + state_file = config.get('state_file', 'state.json') + currency = config.get('currency', 'USD') + + logger.debug('fetching last mined block') + block = flexpoolapi.pool.last_blocks(count=1)[0] + + block_number = block.number + block_time = block.time + block_reward = format_weis(block.total_rewards) + block_round_time = format_timespan(block.round_time) + block_luck = f'{int(block.luck*100)}%' + + if not block: + logger.info('no block found') + return + + if not os.path.isfile(state_file): + logger.debug('creating state file') + write_state(filename=state_file, block_number=block.number) + return + + logger.debug('reading state file') + state = read_state(filename=state_file) + + if block.number != state['block']: + logger.info(f'block {block.number} mined') + + logger.debug(f'block time: {block_time}') + logger.debug(f'block reward: {block_reward}') + logger.debug(f'block round time: {block_round_time}') + logger.debug(f'block luck: {block_luck}') + + logger.debug('fetching miner details') + + miner = flexpoolapi.miner(config['miner']) + miner_balance = miner.balance() + payout_threshold = miner.details().min_payout_threshold + balance_percentage = f'{round(miner_balance*100/payout_threshold, 2)}%' + + logger.debug(f'miner unpaid balance: {format_weis(miner_balance)} ({balance_percentage})') + + logger.debug('fetching current rate') + try: + rate = get_rate(ids='ethereum', vs_currencies=currency) + except HTTPError as err: + logger.error(f'failed to get ETH/{currency} rate') + logger.debug(str(err)) + return + + balance_converted = round(convert_weis(miner_balance)*rate, 2) + logger.debug(f'miner unpaid balance converted: {balance_converted} {currency}') + + if not args.disable_notifications and config.get('telegram'): + logger.debug('sending telegram notification') + variables = {'block_number': block_number, 'block_time': block_time, 'block_reward': block_reward, + 'block_round_time': block_round_time, 'block_luck': block_luck, + 'miner_address': config['miner'], 'miner_balance': format_weis(miner_balance), + 'miner_balance_converted': balance_converted, 'miner_balance_percentage': balance_percentage, + 'currency': currency} + payload = telegram.generate_payload(chat_id=config['telegram']['chat_id'], message_variables=variables) + try: + telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload) + logger.info('notification sent to telegram') + except HTTPError as err: + logger.error('failed to send notification to telegram') + logger.debug(str(err)) + + logger.debug('writing block to state file') + write_state(filename=state_file, block_number=block.number) + + +if __name__ == '__main__': + main() diff --git a/message.md.j2 b/message.md.j2 new file mode 100644 index 0000000..62ef328 --- /dev/null +++ b/message.md.j2 @@ -0,0 +1,11 @@ +*⛏️ Block {{block_number}} mined by Flexpool* + +*Date/Time*: {{block_time}} +*Reward*: {{block_reward}} +*Round time*: {{block_round_time}} +*Luck*: {{block_luck}} + +💰 {{miner_address}} + +*Unpaid balance*: {{miner_balance}} \({{miner_balance_converted}} {{currency}}\) +*Unpaid percentage*: {{miner_balance_percentage}} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00b5825 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +appdirs==1.4.3 +attrs==20.3.0 +CacheControl==0.12.6 +certifi==2019.11.28 +chardet==3.0.4 +colorama==0.4.3 +contextlib2==0.6.0 +distlib==0.3.0 +distro==1.4.0 +flexpoolapi==1.2.7.post1 +html5lib==1.0.1 +humanfriendly==9.1 +idna==2.8 +ipaddr==2.2.0 +Jinja2==2.11.2 +jsonschema==3.2.0 +lockfile==0.12.2 +MarkupSafe==1.1.1 +msgpack==0.6.2 +packaging==20.3 +pep517==0.8.2 +progress==1.5 +pyparsing==2.4.6 +pyrsistent==0.17.3 +pytoml==0.1.21 +requests==2.22.0 +retrying==1.3.3 +si-prefix==1.2.2 +six==1.14.0 +urllib3==1.25.8 +webencodings==0.5.1 diff --git a/state.py b/state.py new file mode 100644 index 0000000..8c8b400 --- /dev/null +++ b/state.py @@ -0,0 +1,12 @@ +import json + + +def write_state(filename, block_number): + data = {'block': block_number} + with open(filename, 'w') as fd: + json.dump(data, fd) + + +def read_state(filename): + with open(filename, 'r') as fd: + return json.load(fd) diff --git a/telegram.py b/telegram.py new file mode 100644 index 0000000..51a0125 --- /dev/null +++ b/telegram.py @@ -0,0 +1,38 @@ +import logging +import os + +import requests +from jinja2 import Environment, FileSystemLoader + +logger = logging.getLogger(__name__) +absolute_path = os.path.split(os.path.abspath(__file__))[0] + + +def markdown_escape(text): + text = str(text) + for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']: + text = text.replace(special_char, fr'\{special_char}') + return text + + +def generate_payload(chat_id, message_variables, template_file_name=None): + payload = {'chat_id': chat_id, 'parse_mode': 'MarkdownV2'} + if not template_file_name: + template_file_name = os.path.join(absolute_path, 'message.md.j2') + template_path = os.path.dirname(os.path.abspath(template_file_name)) + template_name = os.path.basename(os.path.abspath(template_file_name)) + loader = FileSystemLoader(template_path) + env = Environment(loader=loader) + template = env.get_template(template_name) + template_variables = {} + for key, value in message_variables.items(): + template_variables[key] = markdown_escape(value) + text = template.render(**template_variables) + payload['text'] = text + return payload + + +def send_message(auth_key, payload): + logger.debug(payload) + r = requests.post(f'https://api.telegram.org/bot{auth_key}/sendMessage', json=payload) + r.raise_for_status()