Skip to content

Commit

Permalink
v1.5.0 - Telos support + fixes
Browse files Browse the repository at this point in the history
 - Re-factored EOSMixin, EOSLoader, and EOSManager to use the new static attributes `chain`, `chain_type`, and `chain_coin` - allowing for painless
   sub-classing for EOS forks.
     - Moved URL generation code out of EOSMixin.url into EOSMixin._make_url to allow for other methods to generate URLs
     - Added `replace_eos` method for replacing the Cleos instance, e.g. when a new RPC URL needs to be used
     - Adjusted many methods to use `chain` `chain_type` and `chain_coin` instead of hard coded 'eos' / 'EOS' to make sub-classing for forks easier
     - Added `current_rpc` attribute, enabling the ability to check what RPC is currently being used by the Cleos instance
 - Created Telos handler and enabled it by default. Mostly stub code which just sets the chain/chain_type/chain_coin so the EOS handler uses it correctly
 - Improved the EOSLoader list_txs so that it can change the RPC for different tokens if needed
 - Added `enabled=True` to all handler `__init__` files, to prevent handlers trying to load disabled coins and erroring
 - Changed `dotenv.read_dotenv` to `dotenv.load_env` (apparently a different dotenv package is being used now?)
  • Loading branch information
Someguy123 committed Nov 6, 2019
1 parent e38f75d commit 73c3c94
Show file tree
Hide file tree
Showing 15 changed files with 315 additions and 44 deletions.
2 changes: 1 addition & 1 deletion manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"""
if __name__ == '__main__':
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'steemengine.settings')
dotenv.read_dotenv()
dotenv.load_dotenv()
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
Expand Down
2 changes: 1 addition & 1 deletion payments/coin_handlers/Bitcoin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def reload():
settings.COIN_TYPES += (('bitcoind', 'Bitcoind RPC compatible crypto',),)

# Grab a simple list of coin symbols with the type 'bitcoind' to populate the provides lists.
provides = Coin.objects.filter(coin_type='bitcoind').values_list('symbol', flat=True)
provides = Coin.objects.filter(enabled=True, coin_type='bitcoind').values_list('symbol', flat=True)
BitcoinLoader.provides = provides
BitcoinManager.provides = provides
# Since the handler is re-loading, we wipe the settings cache to ensure stale connection details aren't used.
Expand Down
2 changes: 1 addition & 1 deletion payments/coin_handlers/Bitshares/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def reload():
settings.COIN_TYPES += (('bitshares', 'Bitshares Token',),)

# Grab a simple list of coin symbols with the type 'bitshares' to populate the provides lists.
provides = Coin.objects.filter(coin_type='bitshares').values_list('symbol', flat=True)
provides = Coin.objects.filter(enabled=True, coin_type='bitshares').values_list('symbol', flat=True)
BitsharesLoader.provides = provides
BitsharesManager.provides = provides

Expand Down
29 changes: 21 additions & 8 deletions payments/coin_handlers/EOS/EOSLoader.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def load(self, tx_count=1000):
:param tx_count: Amount of transactions to load per account, most recent first
:return: None
"""
log.info('Loading EOS transactions...')
chain = self.chain.upper()
log.info('Loading %s transactions...', chain)

self.tx_count = tx_count
# This just forces self.settings to be loaded before we loop over self.coins
Expand All @@ -41,19 +42,21 @@ def load(self, tx_count=1000):
symbol = symbol.upper()
try:
if empty(coin.our_account):
raise AccountNotFound(f'EOS token "{coin}" has blank `our_account`. Refusing to load TXs.')
raise AccountNotFound(
f'{chain} token "{coin}" has blank `our_account`. Refusing to load TXs.'
)
self.get_contract(symbol)
except Exception as e:
log.warning(f'Refusing to load TXs for EOS token "{coin}". Reason: {type(e)} - {str(e)}')
log.warning(f'Refusing to load TXs for {chain} token "{coin}". Reason: {type(e)} - {str(e)}')
else:
log.debug(f'EOS token with symbol "{coin}" passed tests. Has non-empty our_account and contract.')
log.debug(f'{chain} token with symbol "{coin}" passed tests. Has non-empty our_account and contract.')
safe = True
# If a token didn't pass basic sanity checks (has our account + contract), remove it from coins and symbols.
if not safe:
log.debug(f'Removing symbol "{symbol}" from self.coins and self.symbols...')
del self.coins[symbol]
self.symbols = [s for s in self.symbols if s != symbol]
log.debug('Remaining EOSLoader symbols that were not disabled: %s', self.symbols)
log.debug('Remaining %s symbols that were not disabled: %s', __name__, self.symbols)
self.loaded = True

def list_txs(self, batch=100) -> Generator[dict, None, None]:
Expand All @@ -66,11 +69,21 @@ def list_txs(self, batch=100) -> Generator[dict, None, None]:
"""
if not self.loaded:
self.load()
chain = self.chain.upper()

for symbol, c in self.coins.items():
try:
# If a specific EOS/TELOS token has a different RPC, then it may be on a different chain and we
# need to switch over our RPC.
coin_rpc = c.settings['host']
if not empty(self.current_rpc):
if empty(coin_rpc) and self.setting_defaults['host'] not in self.current_rpc:
self.replace_eos(**self.eos_settings)
elif not empty(coin_rpc) and coin_rpc not in self.current_rpc:
self.replace_eos(**{**c.settings, **c.settings['json']})

sym = c.symbol_id.upper()
log.debug(f'Loading EOS actions for token "{sym}", received to "{c.our_account}"')
log.debug(f'Loading {chain} actions for token "{sym}", received to "{c.our_account}"')
actions = self.get_actions(c.our_account, self.tx_count)
yield from self.clean_txs(c.our_account, sym, self.get_contract(sym), actions)
except:
Expand Down Expand Up @@ -144,11 +157,11 @@ def get_actions(self, account: str, count=100) -> List[dict]:
:param count: Amount of transactions to load
:return list transactions: A list of EOS transactions as dict's
"""
cache_key = f'eos_actions:{account}'
cache_key = f'{self.chain}_actions:{account}'
actions = cache.get(cache_key)

if empty(actions):
log.info('Loading EOS actions for %s from node %s', account, self.url)
log.info('Loading %s actions for %s from node %s', self.chain.upper(), account, self.url)
c = self.eos
data = c.get_actions(account, pos=-1, offset=-count)
actions = data['actions']
Expand Down
25 changes: 14 additions & 11 deletions payments/coin_handlers/EOS/EOSManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ def address_valid(self, *addresses: str) -> bool:
log.warning(f'"account_name" not in data returned by eos.get_account("{address}")...')
return False
except HTTPError as e:
log.info(f'HTTPError while verifying EOS account "{address}" - this is probably normal: {str(e)}')
log.info(f'HTTPError while verifying {self.chain.upper()} account "{address}" '
f'- this is probably normal: {str(e)}')
return False
return True

Expand All @@ -58,7 +59,7 @@ def address_valid_ex(self, *addresses: str):
"""
for address in addresses:
if not self.address_valid(address):
raise AccountNotFound(f'The EOS account "{address}" does not exist...')
raise AccountNotFound(f'The {self.chain.upper()} account "{address}" does not exist...')
return True

def get_deposit(self) -> tuple:
Expand All @@ -69,14 +70,15 @@ def balance(self, address: str = None, memo: str = None, memo_case: bool = False
address = self.coin.our_account

if not empty(memo):
raise NotImplemented('Filtering by memos not implemented yet for EOSManager!')
raise NotImplemented(f'Filtering by memos not implemented yet for {__name__}!')
sym = self.symbol

contract = self.get_contract(sym)

bal = self.eos.get_currency_balance(address, code=contract, symbol=sym)
if len(bal) < 1:
raise TokenNotFound(f'Balance list for EOS symbol {sym} with contract {contract} was empty...')
raise TokenNotFound(f'Balance list for {self.chain.upper()} symbol {sym} with '
f'contract {contract} was empty...')

amt, curr = bal[0].split()
amt = Decimal(amt)
Expand Down Expand Up @@ -177,14 +179,14 @@ def build_tx(self, tx_type, contract, sender, tx_args: dict, key_types=None, bro
tx_bin = self.eos.abi_json_to_bin(payload['account'], payload['name'], tx_args)
payload['data'] = tx_bin['binargs']
trx = dict(actions=[payload])
log.debug(f'Full EOS payload: {trx} Tx Bin: {tx_bin}')
log.debug(f'Full {self.chain.upper()} payload: {trx} Tx Bin: {tx_bin}')
trx['expiration'] = str((datetime.utcnow() + timedelta(seconds=60)).replace(tzinfo=pytz.UTC))
# Sign and broadcast the transaction we've just built
tfr = self.eos.push_transaction(trx, priv_key, broadcast=broadcast)
return tfr

@staticmethod
def get_privkey(from_account: str, key_types: list = None) -> Tuple[str, str]:
@classmethod
def get_privkey(cls, from_account: str, key_types: list = None) -> Tuple[str, str]:
"""
Find the EOS :py:class:`models.CryptoKeyPair` in the database for a given account `from_account` ,
decrypt the private key, then returns a tuple containing (key_type:str, priv_key:str,)
Expand Down Expand Up @@ -212,9 +214,10 @@ def get_privkey(from_account: str, key_types: list = None) -> Tuple[str, str]:

key_types = ['active', 'owner'] if key_types is None else key_types

kp = CryptoKeyPair.objects.filter(network='eos', account=from_account, key_type__in=key_types)
kp = CryptoKeyPair.objects.filter(network=cls.chain_type, account=from_account, key_type__in=key_types)
if len(kp) < 1:
raise AuthorityMissing(f'No private key found for EOS account {from_account} matching types: {key_types}')
raise AuthorityMissing(f'No private key found for {cls.chain.upper()} '
f'account {from_account} matching types: {key_types}')

# Grab the first key pair we've found, and decrypt the private key into plain text
priv_key = decrypt_str(kp[0].private_key)
Expand Down Expand Up @@ -250,11 +253,11 @@ def validate_amount(self, amount: Union[Decimal, float, str], from_account: str
# If we get passed a float for some reason, make sure we trim it to the token's precision before
# converting it to a Decimal.
if type(amount) == float:
amount = '{0:.4f}'.format(amount)
amount = ('{0:.' + self.settings[self.symbol].get('precision', 4) + 'f}').format(amount)

amount = Decimal(amount)
if amount < Decimal('0.0001'):
raise ArithmeticError(f'Amount {amount} is lower than minimum of 0.0001 EOS, cannot send.')
raise ArithmeticError(f'Amount {amount} is lower than minimum of 0.0001 {self.symbol}, cannot send.')

if from_account is not None:
our_bal = self.balance(from_account)
Expand Down
103 changes: 85 additions & 18 deletions payments/coin_handlers/EOS/EOSMixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"""
import logging
from typing import Dict, Any, List
from typing import Dict, Any, List, Optional

from payments.coin_handlers.base import SettingsMixin
from eospy.cleos import Cleos
Expand Down Expand Up @@ -57,19 +57,36 @@ class EOSMixin(SettingsMixin):
"""

chain = 'eos'
"""
This controls the name of the chain and is used for logging, cache keys etc.
It may be converted to upper case for logging, and lower case for cache keys.
Forks of EOS may override this when sub-classing EOSMixin to adjust logging, cache keys etc.
"""
chain_type = chain
"""
Used for looking up 'coin_type=xxx'
Forks of EOS should override this to match the coin_type they use for :py:attr:`.provides` generation
"""
chain_coin = 'EOS'
"""
Forks of EOS may override this when sub-classing EOSMixin to change the native coin symbol of the network
"""

setting_defaults = dict(
host='eos.greymass.com', username=None, password=None, endpoint='/', port=443, ssl=True, precision=4
host='eos.greymass.com', username=None, password=None, endpoint='/', port=443, ssl=True, precision=4,
telos=False,
) # type: Dict[str, Any]
"""Default settings to use if any required values are empty, e.g. default to Greymass's RPC node"""

provides = ['EOS'] # type: List[str]
"""
This attribute is automatically generated by scanning for :class:`models.Coin` s with the type ``eos``.
This saves us from hard coding specific coin symbols. See __init__.py for populating code.
"""

default_contracts = {
'EOS': 'eosio.token'
'EOS': 'eosio.token',
} # type: Dict[str, str]
"""
To make it easier to add common tokens on the EOS network, the loader/manager will fallback to this map between
Expand All @@ -81,7 +98,13 @@ class EOSMixin(SettingsMixin):

_eos = None # type: Cleos
"""Shared instance of :py:class:`eospy.cleos.Cleos` used across both the loader/manager."""


current_rpc: Optional[str]
"""Contains the current EOS API node as a string"""

def __init__(self):
self.current_rpc = None

@property
def all_coins(self) -> Dict[str, Coin]:
"""
Expand All @@ -98,13 +121,15 @@ def all_coins(self) -> Dict[str, Coin]:
else:
raise Exception('Cannot load settings as neither self.coin nor self.coins exists...')

if 'EOS' not in c:
coin = self.chain_coin
if coin not in c:
coin_type = self.chain_type
try:
c['EOS'] = Coin.objects.get(symbol='EOS', coin_type='eos')
c[coin] = Coin.objects.get(symbol=coin, coin_type=coin_type)
except Coin.DoesNotExist:
log.warning('EOSMixin cannot find a coin with the symbol "EOS" and type "eos"...')
log.warning('Checking for a coin with native symbol_id "EOS" and type "eos"...')
c['EOS'] = Coin.objects.get(symbol_id='EOS', coin_type='eos')
log.warning(f'EOSMixin cannot find a coin with the symbol "{coin}" and type "{coin_type}"...')
log.warning(f'Checking for a coin with native symbol_id "{coin}" and type "{coin_type}"...')
c[coin] = Coin.objects.get(symbol_id=coin, coin_type=coin_type)
return c
return c

Expand All @@ -126,33 +151,75 @@ def eos_settings(self) -> Dict[str, Any]:
:return dict settings: A map of setting keys to their values
"""
return super(EOSMixin, self).settings.get('EOS', self.setting_defaults)
return super(EOSMixin, self).settings.get(self.chain_coin, self.setting_defaults)

@property
def eos(self) -> Cleos:
"""Returns an instance of Cleos and caches it in the attribute _eos after creation"""
if not self._eos:
log.debug(f'Creating Cleos instance using EOS API node: {self.url}')
log.debug(f'Creating Cleos instance using {self.chain.upper()} API node: {self.url}')
self.current_rpc = self.url
self._eos = Cleos(url=self.url)
return self._eos


def replace_eos(self, **conn) -> Cleos:
"""
Destroy the EOS :class:`.Cleos` instance at :py:attr:`._eos` and re-create it with the modified
connection settings ``conn``
Also returns the EOS instance for convenience.
Only need to specify settings you want to override.
Example::
>>> eos = self.replace_eos(host='example.com', port=80, ssl=False)
>>> eos.get_account('someguy123')
:param conn: Connection settings. Keys: endpoint, ssl, host, port, username, password
:return Cleos eos: A :class:`.Cleos` instance with the modified connection settings.
"""
del self._eos
url = self._make_url(**conn)
log.debug('Replacing Cleos instance with new %s API node: %s', self.chain.upper(), url)
self.current_rpc = url
self._eos = Cleos(url=url)

return self._eos

@property
def url(self) -> str:
"""Creates a URL from the host settings on the EOS coin"""
s = self.eos_settings
return self._make_url(**self.eos_settings)

def _make_url(self, **conn) -> str:
"""
Generate a Cleos connection URL.
Only need to specify settings you want to override.
Example::
>>> self._make_url(host='example.org', endpoint='/eosrpc')
'https://example.org:443/eosrpc'
:param conn: Connection settings. Keys: endpoint, ssl, host, port, username, password
:return str url: Generated URL
"""
s = {**self.setting_defaults, **conn}

url = s['endpoint']
proto = 'https' if s['ssl'] else 'http'
host = '{}:{}'.format(s['host'], s['port'])

if s['username'] is not None:
host = '{}:{}@{}:{}'.format(s['username'], s['password'], s['host'], s['port'])

url = url[1:] if len(url) > 0 and url[0] == '/' else url # Strip starting / of URL
url = "{}://{}/{}".format(proto, host, url)
# Cleos doesn't like ending slashes, so make sure to remove any ending slashes...
url = url[:-1] if url[-1] == '/' else url

return url

def get_contract(self, symbol: str) -> str:
Expand All @@ -174,7 +241,7 @@ def get_contract(self, symbol: str) -> str:
"""

symbol = symbol.upper()
log.debug(f'Attempting to find EOS contract for "{symbol}" in DB Coin settings')
log.debug(f'Attempting to find {self.chain.upper()} contract for "{symbol}" in DB Coin settings')

try:
contract = self.settings[symbol].get('contract')
Expand Down
2 changes: 1 addition & 1 deletion payments/coin_handlers/EOS/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def reload():
settings.COIN_TYPES += (('eos', 'EOS Token',),)

# Grab a simple list of coin symbols with the type 'bitcoind' to populate the provides lists.
provides = Coin.objects.filter(coin_type='eos').values_list('symbol', flat=True)
provides = Coin.objects.filter(enabled=True, coin_type='eos').values_list('symbol', flat=True)
EOSLoader.provides = provides
EOSManager.provides = provides

Expand Down
2 changes: 1 addition & 1 deletion payments/coin_handlers/Steem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def reload():
settings.COIN_TYPES += (('steembase', 'Steem Network (or compatible fork)',),)

# Grab a simple list of coin symbols with the type 'bitcoind' to populate the provides lists.
provides = Coin.objects.filter(coin_type='steembase').values_list('symbol', flat=True)
provides = Coin.objects.filter(enabled=True, coin_type='steembase').values_list('symbol', flat=True)
SteemLoader.provides = provides
SteemManager.provides = provides

Expand Down
2 changes: 1 addition & 1 deletion payments/coin_handlers/SteemEngine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def reload():
settings.COIN_TYPES += (('steemengine', 'SteemEngine Token',),)

# Grab a simple list of coin symbols with the type 'bitcoind' to populate the provides lists.
provides = Coin.objects.filter(coin_type='steemengine').values_list('symbol', flat=True)
provides = Coin.objects.filter(enabled=True, coin_type='steemengine').values_list('symbol', flat=True)
SteemEngineLoader.provides = provides
SteemEngineManager.provides = provides

Expand Down
Loading

0 comments on commit 73c3c94

Please sign in to comment.