1
0
Fork 0

Add "state" tests

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2021-02-12 09:43:38 +01:00
parent 6f4312cb4f
commit 14aa8a6d59
No known key found for this signature in database
GPG key ID: FF42D23B580C89F7
21 changed files with 116 additions and 3 deletions

0
companion/__init__.py Normal file
View file

14
companion/coingecko.py Normal file
View file

@ -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()]

26
companion/config.py Normal file
View file

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

View file

@ -0,0 +1,40 @@
{
"type": "object",
"properties": {
"telegram": {
"type": "object",
"properties": {
"chat_id": {
"type": "number"
},
"auth_key": {
"type": "string"
}
},
"required": [
"chat_id",
"auth_key"
]
},
"currency": {
"type": "string"
},
"miner": {
"type": "string"
},
"pools": {
"type": "array",
"items": {
"type": "string",
"enum": [
"flexpool",
"ethermine"
]
},
"uniqueItems": true
},
"state_file": {
"type": "string"
}
}
}

91
companion/main.py Normal file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
import argparse
import logging
from coingecko import get_rate
from config import read_config, validate_config
from requests.exceptions import HTTPError
from state import State
logger = logging.getLogger(__name__)
DEFAULT_STATE_FILE = 'state.json'
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 main():
args = parse_arguments()
setup_logging(args)
config = read_config(args.config)
validate_config(config)
state = State(filename=config.get('state_file', DEFAULT_STATE_FILE))
exchange_rate = None
currency = config.get('currency')
notifier = None
if config.get('telegram') and not args.disable_notifications:
from telegram import TelegramNotifier
notifier = TelegramNotifier(**config['telegram'])
if currency:
logger.debug('fetching current rate')
try:
exchange_rate = get_rate(ids='ethereum', vs_currencies=currency)
except HTTPError as err:
logger.warning(f'failed to get ETH/{currency} rate')
logger.debug(str(err))
for pool in config.get('pools', []):
pool_state = state.get(pool)
if pool == 'flexpool':
from pools.flexpool import FlexpoolHandler
handler = FlexpoolHandler(exchange_rate=exchange_rate, currency=currency, notifier=notifier)
elif pool == 'ethermine':
from pools.ethermine import EthermineHandler
handler = EthermineHandler(exchange_rate=exchange_rate, currency=currency, notifier=notifier)
else:
logger.warning(f'pool {pool} not supported')
continue
last_block = handler.watch_blocks(last_block=pool_state.get('block'))
if last_block:
logger.debug(f'saving {pool} block to state file')
state.write(pool_name=pool, block_number=last_block)
if config.get('miner'):
last_balance, last_transaction = handler.watch_miner(address=config['miner'],
last_balance=pool_state.get('balance'),
last_transaction=pool_state.get('payment'))
if last_balance is not None:
logger.debug(f'saving {pool} miner balance to state file')
state.write(pool_name=pool, miner_balance=last_balance)
if last_transaction:
logger.debug(f'saving {pool} miner payment to state file')
state.write(pool_name=pool, miner_payment=last_transaction)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,51 @@
import logging
logger = logging.getLogger(__name__)
MAX_NOTIFICATIONS_COUNT = 5
class Handler:
def __init__(self, pool_name, exchange_rate=None, currency=None, notifier=None):
self.pool_name = pool_name
self.exchange_rate = exchange_rate
self.currency = currency
self.notifier = notifier
def _watch_miner_balance(self, miner, last_balance=None):
logger.debug('watching miner balance')
if miner.raw_balance != last_balance:
logger.info('miner balance has changed')
if self.notifier:
logger.debug('sending balance notification')
arguments = {'pool': self.pool_name, 'address': miner.address, 'url': miner.url,
'balance': miner.balance, 'balance_fiat': miner.balance_fiat,
'balance_percentage': miner.balance_percentage}
try:
self.notifier.notify_balance(**arguments)
logger.info('balance notification sent')
except Exception as err:
logger.error('failed to send notification')
logger.exception(err)
return miner.raw_balance
def _watch_miner_payments(self, miner, last_transaction=None):
if miner.last_transaction and miner.last_transaction.txid != last_transaction:
logger.debug('watching miner payments')
# send notifications for recent payements only
for transaction in miner.transactions[MAX_NOTIFICATIONS_COUNT:]:
if not last_transaction or transaction.txid > last_transaction:
logger.info(f'new payment {transaction.txid}')
if self.notifier:
logger.debug('sending payment notification')
arguments = {'pool': self.pool_name, 'address': miner.address, 'txid': transaction.txid,
'amount': transaction.amount, 'amount_fiat': transaction.amount_fiat,
'time': transaction.time, 'duration': transaction.duration}
try:
self.notifier.notify_payment(**arguments)
logger.info('payment notification sent')
except Exception as err:
logger.error('failed to send notification')
logger.exception(err)
if miner.last_transaction and miner.last_transaction.txid:
return miner.last_transaction.txid

View file

@ -0,0 +1,119 @@
import logging
from datetime import datetime
from ethermine import Ethermine
from humanfriendly import format_timespan
from pools import Handler
from utils import convert_fiat, format_weis
logger = logging.getLogger(__name__)
eth = Ethermine()
class Miner:
def __init__(self, address, exchange_rate=None, currency=None):
self.address = address
self.raw_balance = self.get_unpaid_balance(address)
self.balance = format_weis(self.raw_balance)
self.balance_fiat = None
if exchange_rate and currency:
self.balance_fiat = convert_fiat(amount=self.raw_balance, exchange_rate=exchange_rate, currency=currency)
payout_threshold = self.get_payout_threshold(address)
self.balance_percentage = self.format_balance_percentage(payout_threshold=payout_threshold,
balance=self.raw_balance)
self.transactions = self.get_payouts(address, exchange_rate, currency)
@staticmethod
def get_unpaid_balance(address):
dashboard = eth.miner_dashboard(address)
return dashboard['currentStatistics']['unpaid']
@staticmethod
def get_payouts(address, exchange_rate=None, currency=None):
payouts = eth.miner_payouts(address)
# convert to transactions
transactions = []
for payout in payouts:
transaction = Transaction(txid=payout['txHash'], timestamp=payout['paidOn'], amount=payout['amount'],
duration=payout['end']-payout['start'], exchange_rate=exchange_rate,
currency=currency)
transactions.append(transaction)
# sort by older timestamp first
return sorted(transactions)
@staticmethod
def get_payout_threshold(address):
return eth.miner_settings(address)['minPayout']
@staticmethod
def format_balance_percentage(payout_threshold, balance):
return f'{round(balance*100/payout_threshold, 2)}%'
@property
def url(self):
return f'https://ethermine.org/miners/{self.address}/dashboard'
@property
def last_transaction(self):
if self.transactions:
return self.transactions[-1]
def __repr__(self):
attributes = {'balance': self.balance, 'raw_balance': self.raw_balance,
'balance_percentage': self.balance_percentage, 'url': self.url}
if self.balance_fiat:
attributes['balance_fiat'] = self.balance_fiat
formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()])
return f'<Miner #{self.address} ({formatted_attributes})>'
class Transaction:
def __init__(self, txid, amount, timestamp, duration, exchange_rate=None, currency=None):
self.txid = txid
self.time = datetime.fromtimestamp(timestamp)
self.raw_amount = amount
self.amount = format_weis(amount)
self.amount_fiat = None
self.duration = format_timespan(duration)
if exchange_rate and currency:
self.amount_fiat = convert_fiat(amount=self.raw_amount, exchange_rate=exchange_rate, currency=currency)
def __lt__(self, trx):
"""Order by datetime asc"""
return self.time < trx.time
def __eq__(self, trx):
return self.txid == trx.txid
def __repr__(self):
attributes = {'time': self.time, 'amount': self.amount, 'raw_amount': self.raw_amount,
'duration': self.duration}
if self.amount_fiat:
attributes['amount_fiat'] = self.amount_fiat
formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()])
return f'<Transaction #{self.txid} ({formatted_attributes})>'
class EthermineHandler(Handler):
def __init__(self, exchange_rate=None, currency=None, notifier=None, pool_name='ethermine'):
super().__init__(pool_name=pool_name, exchange_rate=exchange_rate, currency=currency, notifier=notifier)
def watch_blocks(self, last_block=None):
logger.debug('not implemented yet')
def watch_miner(self, address, last_balance=None, last_transaction=None):
logger.debug(f'watching miner {address}')
try:
miner = Miner(address=address, exchange_rate=self.exchange_rate, currency=self.currency)
except Exception as err:
logger.error(f'miner {address} not found')
logger.exception(err)
return
logger.debug(miner)
last_balance = self._watch_miner_balance(miner=miner, last_balance=last_balance)
last_transaction = self._watch_miner_payments(miner=miner, last_transaction=last_transaction)
return last_balance, last_transaction

178
companion/pools/flexpool.py Normal file
View file

@ -0,0 +1,178 @@
import logging
import flexpoolapi
from humanfriendly import format_timespan
from pools import MAX_NOTIFICATIONS_COUNT, Handler
from utils import convert_fiat, convert_weis, format_weis
logger = logging.getLogger(__name__)
MAX_BLOCKS_COUNT = 10
MAX_PAYMENTS_COUNT = 10
class Block:
def __init__(self, number, hash, time, round_time, reward, luck, exchange_rate=None, currency=None):
self.number = int(number)
self.hash = hash
self.time = time
self.round_time = format_timespan(round_time)
self.reward = format_weis(reward)
self.reward_fiat = None
if exchange_rate and currency:
self.reward_fiat = convert_fiat(amount=reward, exchange_rate=exchange_rate, currency=currency)
self.luck = f'{int(luck*100)}%'
def __lt__(self, block):
return self.number < block.number
def __repr__(self):
attributes = {'time': self.time, 'reward': self.reward, 'round_time': self.round_time, 'luck': self.luck}
if self.reward_fiat:
attributes['reward_fiat'] = self.reward_fiat
formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()])
return f'<Block #{self.number} ({formatted_attributes})>'
class Miner:
def __init__(self, address, exchange_rate=None, currency=None):
self.address = address
miner = flexpoolapi.miner(address)
self.raw_balance = miner.balance()
self.balance = convert_weis(self.raw_balance)
if exchange_rate and currency:
self.balance_fiat = convert_fiat(amount=self.raw_balance, exchange_rate=exchange_rate, currency=currency)
payout_threshold = self.get_payout_threshold(miner)
self.balance_percentage = self.format_balance_percentage(payout_threshold=payout_threshold,
balance=self.raw_balance)
self.transactions = self.get_payements(miner)
@property
def url(self):
return f'https://flexpool.io/{self.address}'
@staticmethod
def get_payout_threshold(miner):
return miner.details().min_payout_threshold
@staticmethod
def format_balance_percentage(payout_threshold, balance):
return f'{round(balance*100/payout_threshold, 2)}%'
@staticmethod
def get_payements(miner, exchange_rate=None, currency=None):
# crawl payments
transactions = []
payments_count = 0
current_page = 0
while payments_count < MAX_PAYMENTS_COUNT:
logger.debug(f'fetching payments page {current_page}')
page = miner.payments_paged(page=current_page)
if not page.contents:
break
for payment in page.contents:
# convert to transaction
transaction = Transaction(txid=payment.txid, time=payment.time, amount=payment.amount,
duration=payment.duration, exchange_rate=exchange_rate,
currency=currency)
transactions.append(transaction)
payments_count += 1
current_page += 1
if current_page > page.total_pages:
break
# sort transactions from oldest to newest
return sorted(transactions)
@property
def last_transaction(self):
if self.transactions:
return self.transactions[-1]
def __repr__(self):
attributes = {'balance': self.balance, 'raw_balance': self.raw_balance,
'balance_percentage': self.balance_percentage, 'url': self.url}
if self.balance_fiat:
attributes['balance_fiat'] = self.balance_fiat
formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()])
return f'<Miner #{self.address} ({formatted_attributes})>'
class Transaction:
def __init__(self, txid, amount, time, duration, exchange_rate=None, currency=None):
self.txid = txid
self.time = time
self.raw_amount = amount
self.amount = format_weis(amount)
self.amount_fiat = None
self.duration = format_timespan(duration)
if exchange_rate and currency:
self.amount_fiat = convert_fiat(amount=self.raw_amount, exchange_rate=exchange_rate, currency=currency)
def __lt__(self, trx):
return self.time < trx.time
def __repr__(self):
attributes = {'time': self.time, 'amount': self.amount, 'raw_amount': self.raw_amount,
'duration': self.duration}
if self.amount_fiat:
attributes['amount_fiat'] = self.amount_fiat
formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()])
return f'<Transaction #{self.txid} ({formatted_attributes})>'
class FlexpoolHandler(Handler):
def __init__(self, exchange_rate=None, currency=None, notifier=None, pool_name='flexpool'):
super().__init__(pool_name=pool_name, exchange_rate=exchange_rate, currency=currency, notifier=notifier)
def watch_blocks(self, last_block=None):
logger.debug('watching last blocks')
last_remote_block = None
blocks = self.get_blocks(exchange_rate=self.exchange_rate, currency=self.currency)
if blocks:
# don't spam block notification at initialization
for block in blocks[MAX_NOTIFICATIONS_COUNT:]:
if not last_block or last_block < block.number:
logger.info(f'new block {block.number}')
if self.notifier:
logger.debug('sending block notification')
arguments = {'pool': self.pool_name, 'number': block.number, 'hash': block.hash,
'reward': block.reward, 'time': block.time, 'round_time': block.round_time,
'luck': block.luck, 'reward_fiat': block.reward_fiat}
try:
self.notifier.notify_block(**arguments)
logger.info('block notification sent')
except Exception as err:
logger.error('failed to send notification')
logger.exception(err)
last_remote_block = block
if last_remote_block and last_remote_block.number:
return last_remote_block.number
@staticmethod
def get_blocks(exchange_rate=None, currency=None):
remote_blocks = flexpoolapi.pool.last_blocks(count=MAX_BLOCKS_COUNT)
# convert to blocks
blocks = []
for remote_block in remote_blocks:
block = Block(number=remote_block.number, hash=remote_block.hash, time=remote_block.time,
round_time=remote_block.round_time, reward=remote_block.total_rewards, luck=remote_block.luck,
exchange_rate=exchange_rate, currency=currency)
blocks.append(block)
# sort by block number
return sorted(blocks)
def watch_miner(self, address, last_balance=None, last_transaction=None):
logger.debug(f'watching miner {address}')
try:
miner = Miner(address=address, exchange_rate=self.exchange_rate, currency=self.currency)
except Exception as err:
logger.error(f'miner {address} not found')
logger.exception(err)
return
logger.debug(miner)
last_balance = self._watch_miner_balance(miner=miner, last_balance=last_balance)
last_transaction = self._watch_miner_payments(miner=miner, last_transaction=last_transaction)
return last_balance, last_transaction

34
companion/state.py Normal file
View file

@ -0,0 +1,34 @@
import json
import os
class State:
def __init__(self, filename):
self.filename = filename
self.create()
def create(self):
if not os.path.isfile(self.filename):
with open(self.filename, 'w') as fd:
json.dump({}, fd)
def read(self):
with open(self.filename, 'r') as fd:
return json.load(fd)
def write(self, pool_name, block_number=None, miner_balance=None, miner_payment=None):
content = self.read()
if pool_name not in content:
content[pool_name] = {}
if block_number is not None:
content[pool_name]['block'] = block_number
if miner_balance is not None:
content[pool_name]['balance'] = miner_balance
if miner_payment:
content[pool_name]['payment'] = miner_payment
with open(self.filename, 'w') as fd:
json.dump(content, fd, indent=2, separators=(',', ': '))
def get(self, key):
content = self.read()
return content.get(key, {})

66
companion/telegram.py Normal file
View file

@ -0,0 +1,66 @@
import logging
import os
from copy import copy
import requests
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
absolute_path = os.path.split(os.path.abspath(__file__))[0]
class TelegramNotifier:
def __init__(self, chat_id, auth_key):
self._auth_key = auth_key
self._default_payload = {'auth_key': auth_key, 'chat_id': chat_id, 'parse_mode': 'MarkdownV2',
'disable_web_page_preview': True}
@staticmethod
def _markdown_escape(text):
text = str(text)
for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']:
text = text.replace(special_char, fr'\{special_char}')
return text
def _generate_payload(self, message_variables, template_name):
payload = copy(self._default_payload)
template_path = os.path.join(absolute_path, 'templates')
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] = self._markdown_escape(value)
text = template.render(**template_variables)
payload['text'] = text
return payload
def notify_block(self, pool, number, hash, reward, time, round_time, luck, reward_fiat=None):
message_variables = {'pool': pool, 'number': number, 'hash': hash, 'reward': reward, 'time': time,
'round_time': round_time, 'luck': luck, 'reward_fiat': reward_fiat}
payload = self._generate_payload(message_variables, 'block.md.j2')
self._send_message(payload)
def notify_balance(self, pool, address, url, balance, balance_percentage, balance_fiat=None):
message_variables = {'pool': pool, 'address': address, 'url': url, 'balance': balance,
'balance_percentage': balance_percentage, 'balance_fiat': balance_fiat}
payload = self._generate_payload(message_variables, 'balance.md.j2')
self._send_message(payload)
def notify_payment(self, pool, address, txid, amount, time, duration, amount_fiat=None):
message_variables = {'pool': pool, 'address': address, 'txid': txid, 'amount': amount,
'amount_fiat': amount_fiat, 'time': time, 'duration': duration}
payload = self._generate_payload(message_variables, 'payment.md.j2')
self._send_message(payload)
def _send_message(self, payload):
logger.debug(self._sanitize(payload))
r = requests.post(f'https://api.telegram.org/bot{self._auth_key}/sendMessage', json=payload)
r.raise_for_status()
@staticmethod
def _sanitize(payload):
payload = copy(payload)
if 'auth_key' in payload:
payload['auth_key'] = '***'
return payload

View file

@ -0,0 +1,5 @@
*💰 New {{pool}} balance*
*Address*: [{{address}}]({{url}})
*Unpaid balance*: {{balance}} {% if balance_fiat != 'None' %}\({{balance_fiat}}\){% endif %}
*Unpaid percentage*: {{balance_percentage}}

View file

@ -0,0 +1,7 @@
*⛏️ New {{pool}} block*
*Number*: [{{number}}](https://etherscan.io/block/{{hash}})
*Reward*: {{reward}} {% if reward_fiat != 'None' %}\({{reward_fiat}}\){% endif %}
*Date/Time*: {{time}}
*Round time*: {{round_time}}
*Luck*: {{luck}}

View file

@ -0,0 +1,7 @@
*💵 New {{pool}} payment*
*Amount*: {{amount}} {% if amount_fiat != 'None' %}\({{amount_fiat}}\){% endif %}
*ID*: [{{txid}}](https://etherscan.io/tx/{{txid}})
*Address*: [{{address}}](https://flexpool.io/{{address}})
*Date/Time*: {{time}}
*Duration*: {{duration}}

15
companion/utils.py Normal file
View file

@ -0,0 +1,15 @@
def convert_weis(weis, precision=5):
return round(weis / 10**18, precision)
def format_weis(weis, precision=5):
amount = convert_weis(weis=weis, precision=precision)
if amount == int(amount):
amount = int(amount)
return f'{amount} ETH'
def convert_fiat(amount, exchange_rate, currency):
converted = round(convert_weis(amount)*exchange_rate, 2)
converted = f'{converted} {currency}'
return converted