Rename tool, add ethermine and refactorization
Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
parent
59407a953e
commit
d05d3816b8
18 changed files with 585 additions and 304 deletions
41
README.md
41
README.md
|
@ -1,8 +1,19 @@
|
|||
# flexpool-activity
|
||||
# mining-companion
|
||||
|
||||
[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, display the up-to-date **miner balance** and convert it to **fiat**.
|
||||
Cryptocurrency mining interest has raised recently due to high [Ethereum](https://ethereum.org/en/) profitability. You
|
||||
can opt for the solo-mining path or use a **mining pool** to increase your chances to receive block rewards.
|
||||
|
||||
`mining-companion` is able to listen and notify for the following events:
|
||||
* new **block** is mined by the mining pool
|
||||
* unpaid **balance** is updated
|
||||
* new **payment** has been sent by the mining pool
|
||||
|
||||
Notifications are sent via [Telegram Messenger](https://telegram.org/).
|
||||
|
||||
## Supported pools
|
||||
|
||||
* [Ethermine](https://ethermine.org/)
|
||||
* [Flexpool.io](https://flexpool.io)
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -10,6 +21,20 @@ mined by the pool, display the up-to-date **miner balance** and convert it to **
|
|||
sudo apt install python3-virtualenv
|
||||
virtualenv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
Pool libraries are loaded at execution time. For example, if you use only "flexpool" mining pool, you don't need to
|
||||
install "ethermine" libraries. Requirements files have been splitted to install only libraries you need.
|
||||
|
||||
```
|
||||
pip install -r requirements/base.txt
|
||||
pip install -r requirements/ethermine.txt
|
||||
pip install -r requirements/flexpool.txt
|
||||
```
|
||||
|
||||
To install all libraries at once:
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
|
@ -21,15 +46,17 @@ 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 (optional)
|
||||
* `currency`: symbol of the currency to convert (optional)
|
||||
* `telegram`: send notifications with Telegram (optional)
|
||||
* `pools`: list of mining pools
|
||||
* `miner`: wallet address of the miner
|
||||
* `currency`: symbol of the currency to convert
|
||||
* `telegram`: send notifications with Telegram
|
||||
* `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).
|
||||
|
||||
All options are optional (but the companion would do nothing).
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"miner": "string",
|
||||
"pools": [
|
||||
"ethermine",
|
||||
"flexpool"
|
||||
],
|
||||
"miner": "your.eth.address",
|
||||
"currency": "USD",
|
||||
"telegram": {
|
||||
"chat_id": 123,
|
||||
|
|
|
@ -22,6 +22,17 @@
|
|||
"miner": {
|
||||
"type": "string"
|
||||
},
|
||||
"pools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"flexpool",
|
||||
"ethermine"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"state_file": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
113
flexpool.py
113
flexpool.py
|
@ -1,113 +0,0 @@
|
|||
import flexpoolapi
|
||||
from flexpoolapi.utils import format_weis
|
||||
from humanfriendly import format_timespan
|
||||
|
||||
|
||||
class BlockNotFoundException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def convert_weis(weis, prec=5):
|
||||
return round(weis / 10**18, prec)
|
||||
|
||||
|
||||
class LastBlock:
|
||||
def __init__(self, exchange_rate=None, currency=None):
|
||||
self._exchange_rate = exchange_rate
|
||||
self._currency = currency
|
||||
block = self.get_last_block()
|
||||
self.hash = block.hash
|
||||
self.number = block.number
|
||||
self.time = block.time
|
||||
self.raw_reward = block.total_rewards
|
||||
self.reward = format_weis(block.total_rewards)
|
||||
self.reward_fiat = self.convert_reward()
|
||||
self.round_time = format_timespan(block.round_time)
|
||||
self.luck = f'{int(block.luck*100)}%'
|
||||
|
||||
@staticmethod
|
||||
def get_last_block():
|
||||
block = flexpoolapi.pool.last_blocks(count=1)[0]
|
||||
if not block:
|
||||
raise BlockNotFoundException('No block found')
|
||||
return block
|
||||
|
||||
def convert_reward(self):
|
||||
if self._exchange_rate and self._currency:
|
||||
converted = round(convert_weis(self.raw_reward)*self._exchange_rate, 2)
|
||||
converted = f'{converted} {self._currency}'
|
||||
return converted
|
||||
|
||||
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
|
||||
self._exchange_rate = exchange_rate
|
||||
self._currency = currency
|
||||
miner = flexpoolapi.miner(address)
|
||||
self.raw_balance = miner.balance()
|
||||
self.details = miner.details()
|
||||
self.balance = self.format_balance()
|
||||
self.balance_fiat = self.convert_balance()
|
||||
self.balance_percentage = self.format_balance_percentage()
|
||||
|
||||
last_transactions = miner.payments_paged(page=0)
|
||||
if last_transactions:
|
||||
trx = last_transactions[0]
|
||||
self.last_transaction = Transaction(amount=trx.amount, time=trx.time, duration=trx.duration, txid=trx.txid,
|
||||
exchange_rate=exchange_rate, currency=currency)
|
||||
else:
|
||||
self.last_transaction = None
|
||||
|
||||
def format_balance(self):
|
||||
return format_weis(self.raw_balance)
|
||||
|
||||
def format_balance_percentage(self):
|
||||
return f'{round(self.raw_balance*100/self.details.min_payout_threshold, 2)}%'
|
||||
|
||||
def convert_balance(self):
|
||||
if self._exchange_rate and self._currency:
|
||||
converted = round(convert_weis(self.raw_balance)*self._exchange_rate, 2)
|
||||
converted = f'{converted} {self._currency}'
|
||||
return converted
|
||||
|
||||
def __repr__(self):
|
||||
attributes = {'balance': self.balance, 'raw_balance': self.raw_balance,
|
||||
'balance_percentage': self.balance_percentage}
|
||||
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, amount, time, duration, txid, exchange_rate=None, currency=None):
|
||||
self._exchange_rate = exchange_rate
|
||||
self._currency = currency
|
||||
self.raw_amount = amount
|
||||
self.amount = format_weis(amount)
|
||||
self.amount_fiat = self.convert_amount()
|
||||
self.duration = format_timespan(duration)
|
||||
self.txid = txid
|
||||
self.time = time
|
||||
|
||||
def convert_amount(self):
|
||||
if self._exchange_rate and self._currency:
|
||||
converted = round(convert_weis(self.raw_amount)*self._exchange_rate, 2)
|
||||
converted = f'{converted} {self._currency}'
|
||||
return converted
|
||||
|
||||
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})>'
|
130
main.py
130
main.py
|
@ -2,12 +2,10 @@
|
|||
import argparse
|
||||
import logging
|
||||
|
||||
import telegram
|
||||
from coingecko import get_rate
|
||||
from config import read_config, validate_config
|
||||
from flexpool import BlockNotFoundException, LastBlock, Miner
|
||||
from requests.exceptions import HTTPError
|
||||
from state import create_state, read_state, write_state
|
||||
from state import State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -34,81 +32,6 @@ def setup_logging(args):
|
|||
logging.basicConfig(format=log_format, level=args.loglevel, filename=args.logfile)
|
||||
|
||||
|
||||
def watch_block(config, disable_notifications, last_block=None, exchange_rate=None, currency=None):
|
||||
logger.debug('fetching last mined block')
|
||||
try:
|
||||
block = LastBlock(exchange_rate=exchange_rate, currency=currency)
|
||||
except BlockNotFoundException:
|
||||
logger.warning('last block found')
|
||||
return
|
||||
|
||||
if block.number != last_block:
|
||||
logger.info(f'block {block.number} mined')
|
||||
logger.debug(block)
|
||||
|
||||
if not disable_notifications and config.get('telegram'):
|
||||
logger.debug('sending block notification to telegram')
|
||||
variables = {'hash': block.hash, 'number': block.number, 'time': block.time, 'reward': block.reward,
|
||||
'reward_fiat': block.reward_fiat, 'round_time': block.round_time, 'luck': block.luck}
|
||||
payload = telegram.create_block_payload(chat_id=config['telegram']['chat_id'], message_variables=variables)
|
||||
try:
|
||||
telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload)
|
||||
logger.info('block notification sent to telegram')
|
||||
except HTTPError as err:
|
||||
logger.error('failed to send notification to telegram')
|
||||
logger.exception(err)
|
||||
|
||||
return block
|
||||
|
||||
|
||||
def watch_miner(address, config, disable_notifications, last_balance=None, last_transaction=None, exchange_rate=None,
|
||||
currency=None):
|
||||
logger.debug(f'watching miner {address}')
|
||||
try:
|
||||
miner = Miner(address=address, exchange_rate=exchange_rate, currency=currency)
|
||||
except Exception as err:
|
||||
logger.error('failed to find miner')
|
||||
logger.exception(err)
|
||||
return
|
||||
|
||||
logger.debug(miner)
|
||||
|
||||
logger.debug('watching miner balance')
|
||||
if miner.raw_balance != last_balance:
|
||||
logger.info(f'miner {address} balance has changed')
|
||||
if not disable_notifications and config.get('telegram'):
|
||||
logger.debug('sending balance notification to telegram')
|
||||
variables = {'address': address, 'balance': miner.balance, 'balance_fiat': miner.balance_fiat,
|
||||
'balance_percentage': miner.balance_percentage}
|
||||
payload = telegram.create_balance_payload(chat_id=config['telegram']['chat_id'],
|
||||
message_variables=variables)
|
||||
try:
|
||||
telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload)
|
||||
logger.info('balance notification sent to telegram')
|
||||
except HTTPError as err:
|
||||
logger.error('failed to send notification to telegram')
|
||||
logger.debug(str(err))
|
||||
|
||||
logger.debug('watching miner payments')
|
||||
if miner.last_transaction and miner.last_transaction.txid != last_transaction:
|
||||
logger.info(f'new payment for miner {address}')
|
||||
if not disable_notifications and config.get('telegram'):
|
||||
logger.debug('sending payment notification to telegram')
|
||||
variables = {'address': address, 'txid': miner.last_transaction.txid,
|
||||
'amount': miner.last_transaction.amount, 'amount_fiat': miner.last_transaction.amount_fiat,
|
||||
'time': miner.last_transaction.time, 'duration': miner.last_transaction.duration}
|
||||
payload = telegram.create_payment_payload(chat_id=config['telegram']['chat_id'],
|
||||
message_variables=variables)
|
||||
try:
|
||||
telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload)
|
||||
logger.info('payment notification sent to telegram')
|
||||
except HTTPError as err:
|
||||
logger.error('failed to send notification to telegram')
|
||||
logger.debug(str(err))
|
||||
|
||||
return miner
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
setup_logging(args)
|
||||
|
@ -116,13 +39,16 @@ def main():
|
|||
config = read_config(args.config)
|
||||
validate_config(config)
|
||||
|
||||
state_file = config.get('state_file', DEFAULT_STATE_FILE)
|
||||
create_state(state_file)
|
||||
state = read_state(state_file)
|
||||
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:
|
||||
|
@ -131,22 +57,34 @@ def main():
|
|||
logger.warning(f'failed to get ETH/{currency} rate')
|
||||
logger.debug(str(err))
|
||||
|
||||
block = watch_block(last_block=state.get('block'), config=config, disable_notifications=args.disable_notifications,
|
||||
exchange_rate=exchange_rate, currency=currency)
|
||||
if block:
|
||||
logger.debug('saving block number to state file')
|
||||
write_state(state_file, block_number=block.number)
|
||||
for pool in config.get('pools', []):
|
||||
pool_state = state.get(pool)
|
||||
|
||||
if config.get('miner'):
|
||||
miner = watch_miner(last_balance=state.get('balance'), last_transaction=state.get('payment'),
|
||||
address=config['miner'], config=config, disable_notifications=args.disable_notifications,
|
||||
exchange_rate=exchange_rate, currency=currency)
|
||||
if miner:
|
||||
logger.debug('saving miner balance to state file')
|
||||
write_state(state_file, miner_balance=miner.raw_balance)
|
||||
if miner.last_transaction and miner.last_transaction.txid:
|
||||
logger.debug('saving miner payment to state file')
|
||||
write_state(state_file, miner_payment=miner.last_transaction.txid)
|
||||
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:
|
||||
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__':
|
||||
|
|
51
pools/__init__.py
Normal file
51
pools/__init__.py
Normal 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
|
119
pools/ethermine.py
Normal file
119
pools/ethermine.py
Normal 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
pools/flexpool.py
Normal file
178
pools/flexpool.py
Normal 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
|
|
@ -1,31 +1,3 @@
|
|||
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.post2
|
||||
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
|
||||
-r requirements/base.txt
|
||||
-r requirements/ethermine.txt
|
||||
-r requirements/flexpool.txt
|
||||
|
|
29
requirements/base.txt
Normal file
29
requirements/base.txt
Normal file
|
@ -0,0 +1,29 @@
|
|||
appdirs==1.4.4
|
||||
attrs==20.3.0
|
||||
CacheControl==0.12.6
|
||||
certifi==2020.12.5
|
||||
chardet==4.0.0
|
||||
colorama==0.4.3
|
||||
contextlib2==0.6.0
|
||||
distlib==0.3.1
|
||||
distro==1.4.0
|
||||
html5lib==1.0.1
|
||||
humanfriendly==9.1
|
||||
idna==2.10
|
||||
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.25.1
|
||||
retrying==1.3.3
|
||||
six==1.15.0
|
||||
urllib3==1.26.3
|
||||
webencodings==0.5.1
|
9
requirements/ethermine.txt
Normal file
9
requirements/ethermine.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
cfgv==3.2.0
|
||||
ethermine==0.2.0
|
||||
filelock==3.0.12
|
||||
identify==1.5.10
|
||||
nodeenv==1.5.0
|
||||
pre-commit==2.9.2
|
||||
PyYAML==5.3.1
|
||||
toml==0.10.2
|
||||
virtualenv==20.2.1
|
18
requirements/flexpool.txt
Normal file
18
requirements/flexpool.txt
Normal file
|
@ -0,0 +1,18 @@
|
|||
appdirs==1.4.4
|
||||
certifi==2020.12.5
|
||||
cfgv==3.2.0
|
||||
chardet==4.0.0
|
||||
distlib==0.3.1
|
||||
filelock==3.0.12
|
||||
flexpoolapi==1.2.7.post2
|
||||
identify==1.5.10
|
||||
idna==2.10
|
||||
nodeenv==1.5.0
|
||||
pre-commit==2.9.2
|
||||
PyYAML==5.3.1
|
||||
requests==2.25.1
|
||||
si-prefix==1.2.2
|
||||
six==1.15.0
|
||||
toml==0.10.2
|
||||
urllib3==1.26.3
|
||||
virtualenv==20.2.1
|
48
state.py
48
state.py
|
@ -2,29 +2,33 @@ import json
|
|||
import os
|
||||
|
||||
|
||||
def read_state(filename):
|
||||
with open(filename, 'r') as fd:
|
||||
return json.load(fd)
|
||||
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 write_state(filename, block_number=None, miner_balance=None, miner_payment=None):
|
||||
data = {}
|
||||
if os.path.isfile(filename):
|
||||
data = read_state(filename)
|
||||
def read(self):
|
||||
with open(self.filename, 'r') as fd:
|
||||
return json.load(fd)
|
||||
|
||||
if block_number:
|
||||
data['block'] = block_number
|
||||
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:
|
||||
content[pool_name]['block'] = block_number
|
||||
if miner_balance:
|
||||
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=(',', ': '))
|
||||
|
||||
if miner_balance:
|
||||
data['balance'] = miner_balance
|
||||
|
||||
if miner_payment:
|
||||
data['payment'] = miner_payment
|
||||
|
||||
with open(filename, 'w') as fd:
|
||||
json.dump(data, fd)
|
||||
|
||||
|
||||
def create_state(filename):
|
||||
if not os.path.isfile(filename):
|
||||
write_state(filename)
|
||||
def get(self, key):
|
||||
content = self.read()
|
||||
return content.get(key, {})
|
||||
|
|
79
telegram.py
79
telegram.py
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
import os
|
||||
from copy import copy
|
||||
|
||||
import requests
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
@ -8,40 +9,58 @@ 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
|
||||
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 create_block_payload(chat_id, message_variables):
|
||||
return generate_payload(chat_id, message_variables, 'block.md.j2')
|
||||
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, '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 create_balance_payload(chat_id, message_variables):
|
||||
return generate_payload(chat_id, message_variables, 'balance.md.j2')
|
||||
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 create_payment_payload(chat_id, message_variables):
|
||||
return generate_payload(chat_id, message_variables, 'payment.md.j2')
|
||||
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()
|
||||
|
||||
|
||||
def generate_payload(chat_id, message_variables, template_name):
|
||||
payload = {'chat_id': chat_id, 'parse_mode': 'MarkdownV2', 'disable_web_page_preview': True}
|
||||
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] = 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()
|
||||
@staticmethod
|
||||
def _sanitize(payload):
|
||||
payload = copy(payload)
|
||||
if 'auth_key' in payload:
|
||||
payload['auth_key'] = '***'
|
||||
return payload
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
*💰 New balance*
|
||||
*💰 New {{pool}} balance*
|
||||
|
||||
*Address*: [{{address}}](https://flexpool.io/{{address}})
|
||||
*Address*: [{{address}}]({{url}})
|
||||
*Unpaid balance*: {{balance}} {% if balance_fiat != 'None' %}\({{balance_fiat}}\){% endif %}
|
||||
*Unpaid percentage*: {{balance_percentage}}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
*⛏️ New block*
|
||||
*⛏️ New {{pool}} block*
|
||||
|
||||
*Number*: [{{number}}](https://etherscan.io/block/{{hash}})
|
||||
*Reward*: {{reward}} {% if reward_fiat != 'None' %}\({{reward_fiat}}\){% endif %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
*💵 New payment*
|
||||
*💵 New {{pool}} payment*
|
||||
|
||||
*Amount*: {{amount}} {% if amount_fiat != 'None' %}\({{amount_fiat}}\){% endif %}
|
||||
*ID*: [{{txid}}](https://etherscan.io/tx/{{txid}})
|
||||
|
|
15
utils.py
Normal file
15
utils.py
Normal 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
|
Reference in a new issue