def pvx_get_actions(self, account: str, count=100, symbol=None, contract=None, history_url=None) -> List[dict]: """ Loads EOS transactions for a given account, and caches them per account to avoid constant queries. :param account: The EOS account to load transactions for :param count: Amount of transactions to load :return list transactions: A list of EOS transactions as dict's """ cache_key = f'{self.chain}_pvx_actions:{account}' actions = cache.get(cache_key) if empty(history_url): history_url = self.setting_defaults.get('history_url') if empty(actions): log.info('Loading %s actions for %s from history API %s', self.chain.upper(), account, history_url) # c = self.eos # data = c.get_actions(account, pos=-1, offset=-count) url = f"{history_url}/api/actions/?limit={count}&tx_to={account}" if not empty(symbol): url += f"&symbol={symbol}" if not empty(contract): url += f"&account={contract}" req = requests.get(url) actions = req.json()['results'] # log.info('%s %s', url, actions) cache.set(cache_key, actions, timeout=60) return actions
def refund_sender(deposit: Deposit, reason: str = None, return_to: str = None) -> Tuple[Deposit, dict]: """ Returns a Deposit back to it's original sender, sets the Deposit status to 'refund', and stores the refund details onto the Deposit. :param Deposit deposit: The :class:`models.Deposit` object to refund :param str reason: If specified, will use this instead of ``deposit.error_reason`` for the memo. :param str return_to: If specified, will return to this addr/acc instead of deposit.address/from_account :return tuple refund_data: Returns a tuple containing the updated Deposit object, and the tx data from send(). """ d = deposit reason = d.error_reason if empty(reason) else reason if empty(reason): reason = f'Returned to sender due to unknown error processing deposit amount {d.amount} ' \ f'with TXID {d.txid}...' log.info(f'Refunding Deposit {d} due to reason: {reason}') if d.status == 'refund': raise ConvertError(f'The deposit {d} is already refunded!') if d.status == 'conv': raise ConvertError( f'The deposit {d} is already successfully converted!') c = d.coin sym = c.symbol.upper() mgr = get_manager(sym) # Return destination priority: ``return_to`` arg, sender address, sender account dest = d.address if empty(return_to) else return_to dest = d.from_account if empty(dest) else dest if empty(dest): raise AttributeError( 'refund_sender could not find any non-empty return address/account...' ) metadata = dict(deposit=deposit, action="refund") log.info( f'(REFUND) Sending {d.amount} {sym} to addr/acc {dest} with memo "{reason}"' ) txdata = mgr.send_or_issue(amount=d.amount, address=dest, memo=reason, trigger_data=metadata) log.debug(f'(REFUND) Storing refund details for deposit {d}') d.status = 'refund' d.refund_address = dest d.refund_amount = txdata['amount'] d.refund_coin = sym d.refund_memo = reason d.refund_txid = txdata['txid'] d.refunded_at = timezone.now() d.save() log.info(f'(REFUND) SUCCESS. Saved details for {d}') return d, txdata
def get_contract(self, symbol: str) -> str: """ Attempt to find the contract account for a given token symbol, searches the database Coin objects first using :py:attr:`.settings` - if not found, falls back to :py:attr:`.default_contracts` Example usage:: >>> contract_acc = self.get_contract('EOS') >>> print(contract_acc) eosio.token :param str symbol: The token symbol to find the contract for, e.g. ``EOS`` :raises TokenNotFound: The given ``symbol`` does not exist in self.settings :raises MissingTokenMetadata: Could not find contract in DB coin settings nor default_contracts :return str contract_acc: The contract username as a string, e.g. ``eosio.token`` """ symbol = symbol.upper() log.debug( f'Attempting to find EOS contract for "{symbol}" in DB Coin settings' ) try: contract = self.settings[symbol].get('contract') if not empty(contract): return contract except AttributeError: raise TokenNotFound( f'The coin "{symbol}" was not found in {__name__}.settings') log.debug( f'No contract found in DB settings for "{symbol}", checking if we have a default...' ) try: contract = self.default_contracts[symbol] if empty(contract): raise MissingTokenMetadata log.debug( f'Found contract for "{symbol}" in default_contracts, returning "{contract}"' ) return contract except (AttributeError, MissingTokenMetadata): log.error( f'Failed to find a contract for "{symbol}" in Coin objects nor default_contracts...' ) raise MissingTokenMetadata( f"Couldn't find '{symbol}' contract in DB coin settings or default_contracts." )
def balance(self, address: str = None, memo: str = None, memo_case: bool = False) -> Decimal: if not address: address = self.coin.our_account if not empty(memo): raise NotImplemented( 'Filtering by memos not implemented yet for EOSManager!') 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...' ) amt, curr = bal[0].split() amt = Decimal(amt) if curr.upper() != sym: raise CoinHandlerException( f'Expected balance currency of {sym} but got {curr} - aborting' ) return amt
def _clean_settings(self, d_settings: Dict[str, dict]) -> Dict[str, dict]: """ Clean up ``d_settings`` by setting any missing/empty settings to default values, and cast non-string settings to the correct types. :param dict d_settings: The dict<str,dict> mapping symbol->settings to clean up :return dict d_settings: The cleaned dictionary. Only needed if you passed a deep-copy for d_settings, as the passed dict will be altered in-place unless it's a copy. """ defs = self._bc_defaults # Loop over each symbol and settings dict we were passed for sym, conn in d_settings.items( ): # coin symbol : str, settings: dict # log.debug("Cleaning settings for symbol %s", sym) z = d_settings[sym] # Pointer to the settings dict for this symbol # Loop over our default settings, compare to the user's settings for def_key, def_val in defs.items( ): # settings key : str, settings value : any # Check if required setting key exists in user's settings if def_key in z and not empty(z[def_key]): continue # Setting doesn't exist, or was empty. Update user's setting to our default. z[def_key] = def_val # Cast settings keys to avoid casting errors z['confirms_needed'] = int(z['confirms_needed']) z['port'] = int(z['port']) z['use_trusted'] = z['use_trusted'] in [ True, 'true', 'True', 'TRUE', 1, 'yes' ] return d_settings
def balance(self, address: str = None, memo: str = None, memo_case: bool = False) -> Decimal: """ Get token balance for a given Steem account, if memo is given - get total symbol amt received with this memo. :param address: Steem account to get balance for, if not set, uses self.coin.our_account :param memo: If not None, get total `self.symbol` received with this memo. :param memo_case: Case sensitive memo search :return: Decimal(balance) """ if address is None: address = self.coin.our_account address = address.lower() if memo is not None: memo = str(memo).strip() if empty(memo): return self.eng_rpc.get_token_balance(user=address, symbol=self.symbol) txs = self.eng_rpc.list_transactions(user=address, symbol=self.symbol, limit=1000) bal = Decimal(0) for t in txs: if t['to'] == address and t['symbol'] == self.symbol: m = t['memo'].strip() if m == memo or (not memo_case and m == memo.lower()): bal += Decimal(t['quantity']) return bal
def load(self, tx_count=1000): """ Simply imports `tx_count` into an instance variable, and then sets `self.loaded` to True. If `self.need_account` is set to True by a child/parent class, this method will remove any coins from `self.coins` and `self.symbols` which have a blank/null `our_account` in the DB, ensuring that you can trust that all coins listed in symbols/coins have an `our_account` which isn't empty or None. :param int tx_count: The amount of transactions to load per symbol specified in constructor """ log.debug('Initialising %s with TX count %d', type(self).__name__, tx_count) self.tx_count = tx_count if not self.need_account: self.loaded = True return for symbol, coin in self.coins.items(): if not empty(coin.our_account): continue log.warning( 'The coin %s does not have `our_account` set. Refusing to load transactions.', coin) del self.coins[symbol] self.symbols = [s for s in self.symbols if s != symbol] self.loaded = True
def balance(self, address: str = None, memo: str = None, memo_case: bool = False) -> Decimal: """ Get token balance for a given Bitshares account, if memo is given - get total symbol amt received with this memo. :param address: Bitshares account to get balance for, if not set, uses self.coin.our_account :param memo: If not None, get total `self.symbol` received with this memo. :param memo_case: Case sensitive memo search :raises AccountNotFound: The requested account/address doesn't exist :return: Decimal(balance) """ balance = Decimal(0) if address is None: address = self.coin.our_account if not empty(memo): raise NotImplemented( 'Filtering by memos not implemented yet for BitsharesManager!') address = address.strip().lower() account_obj = self.get_account_obj(address) if account_obj is None: raise exceptions.AccountNotFound( f'Account {address} does not exist') asset_obj = self.get_asset_obj(self.symbol) if asset_obj is not None: amount_obj = account_obj.balance(self.symbol) balance = self.get_decimal_from_amount(amount_obj) return balance
def balance(self, address: str = None, memo: str = None, memo_case: bool = False) -> Decimal: """ Get token balance for a given Steem account, if memo is given - get total symbol amt received with this memo. :param address: Steem account to get balance for, if not set, uses self.coin.our_account :param memo: If not None, get total `self.symbol` received with this memo. :param memo_case: Case sensitive memo search :return: Decimal(balance) """ if not address: address = self.coin.our_account acc = Account(address, steem_instance=self.rpc) if not empty(memo): hist = acc.get_account_history(-1, 10000, only_ops=['transfer']) total = Decimal(0) for h in hist: tx = SteemLoader.clean_tx(h, self.symbol, address, memo) if tx is None: continue total += tx['amount'] return total bal = acc.get_balance('available', self.symbol) return Decimal(bal.amount)
def issue(self, amount: Decimal, address: str, memo: str = None, trigger_data=None): acc = self.coin.our_account # Some basic sanity checks, e.g. do the from/to account exist? validate/cast the sending amount self.address_valid_ex(acc, address) memo = "" if empty(memo) else memo # Note: since we're issuing, no from_account kwarg to avoid NotEnoughBalance exceptions amount = self.validate_amount(amount=amount) # Grab the coin's symbol and find it's contract account sym, contract = self.symbol, self.get_contract(self.symbol) # Craft the transaction arguments for the issue operation, then broadcast it and get the result precision = self.settings[self.symbol].get('precision', 4) amt = f"{amount:.{precision}f} {sym}" tx_args = {"to": address, "quantity": amt, "memo": memo} tfr = self.build_tx("issue", contract, acc, tx_args) # Some of the important data, e.g. how much was actually sent, is buried in the processed>action_traces tx_output = tfr['processed']['action_traces'][0]['act']['data'] tx_amt_final = Decimal(tx_output['quantity'].split()[0]) return { 'txid': tfr['transaction_id'], 'coin': sym, 'amount': tx_amt_final, 'fee': Decimal(0), 'from': acc, 'send_type': 'issue' }
def clean_tx(self, tx: dict, symbol: str, account: str, memo: str = None, memo_case: bool = False) -> Union[dict, None]: """Filters an individual transaction. See :meth:`.clean_txs` for info""" # log.debug(tx) if tx.get('type', 'NOT SET') != 'transfer': log.debug('Steem TX is not transfer. Type is: %s', tx.get('type', 'NOT SET')) return None txid = tx.get('trx_id', None) _am = tx[ 'amount'] # Transfer ops contain a dict 'amount', containing amount:int, nai:str, precision:int if type(_am ) is str: # Extract and validate asset 'ABC' from '12.345 ABC' amt, _symbol = _am.split() _asset = Asset(symbol, steem_instance=self.get_rpc(symbol)) else: # Conv asset ID (e.g. @@000000021) to symbol, i.e. "STEEM" _asset = Asset(_am['nai'], steem_instance=self.get_rpc(symbol)) # Convert integer amount/precision to Decimal's, preventing floating point issues amt_int = Decimal(_am['amount']) amt_prec = Decimal(_am['precision']) amt = amt_int / ( Decimal(10)**amt_prec ) # Use precision value to convert from integer amt to decimal amt # Get validated symbol from beem Asset amt_sym = str(_asset.symbol) if amt_sym != symbol: # If the symbol doesn't match the symbol we were passed, skip this TX return None tx_memo = tx.get('memo') log.debug('Filtering/cleaning steem transaction, Amt: %f, TXID: %s', amt, txid) if tx['to'] != account or tx['from'] == account: return None # If the transaction isn't to us (account), or it's from ourselves, ignore it. if not empty(memo) and (tx_memo != memo or (not memo_case and tx_memo.lower() != memo.lower())): return None d = parse(tx['timestamp']) d = timezone.make_aware(d, pytz.UTC) return dict(txid=txid, coin=symbol, vout=int(tx.get('op_in_trx', 0)), tx_timestamp=d, from_account=tx.get('from', None), to_account=tx.get('to', None), memo=tx_memo, amount=Decimal(amt))
def load(self, tx_count=1000): log.info('Loading Steem Engine transactions...') # with transaction.atomic(): self.tx_count = tx_count for symbol, coin in self.coins.items(): if empty(coin.our_account): log.warning('The coin %s does not have `our_account` set. Refusing to load transactions.', coin) del self.coins[symbol] self.symbols = [s for s in self.symbols if s != symbol] self.loaded = True
def save(self, *args, **kwargs): """To avoid inconsistency, the symbol is automatically made uppercase""" self.symbol = self.symbol.upper() if empty(self.symbol_id): self.symbol_id = self.symbol super(Coin, self).save(*args, **kwargs) # After a coin is updated in the DB, we should reload any coin_handlers to detect compatible loaders. # We need to use in-line loading to prevent recursive loading from the coin handlers causing issues. from payments.coin_handlers import reload_handlers reload_handlers()
def load(self, tx_count=1000): # Unlike other coins, it's important to load a lot of TXs, because many won't actually be transfers # Thus the default TX count for Hive is 1000 self.tx_count = tx_count for symbol, coin in self.coins.items(): if not empty(coin.our_account): continue log.warning('The coin %s does not have `our_account` set. Refusing to load transactions.', coin) del self.coins[symbol] self.symbols = [s for s in self.symbols if s != symbol]
def send(self, amount, address, from_address=None, memo=None) -> dict: """ Send a given ``amount`` of EOS (or a token on EOS) from ``from_address`` to ``address`` with the memo ``memo``. Only ``amount`` and ``address`` are mandatory. :param Decimal amount: Amount of coins/tokens to send, as a Decimal() :param str address: Destination EOS account to send the coins/tokens to :param str memo: Memo to send coins/tokens with (default: "") :param str from_address: EOS Account to send from (default: uses Coin.our_account) :raises AuthorityMissing: Cannot send because we don't have authority to (missing key etc.) :raises AccountNotFound: The requested account doesn't exist :raises NotEnoughBalance: Sending account/address does not have enough balance to send :return dict: Result Information Format:: dict { txid:str - Transaction ID - None if not known, coin:str - Symbol that was sent, amount:Decimal - The amount that was sent (after fees), fee:Decimal - TX Fee that was taken from the amount (static Decimal(0) for EOS) from:str - The account the coins were sent from. send_type:str - Statically set to "send" } """ # Fallback to the coin's `our_account` if `from_address` is not specified from_address = self.coin.our_account if not from_address else from_address # Some basic sanity checks, e.g. do the from/to account exist? validate/cast the sending amount self.address_valid_ex(from_address, address) memo = "" if empty(memo) else memo amount = self.validate_amount(amount=amount, from_account=from_address) # Grab the coin's symbol and find it's contract account sym, contract = self.symbol, self.get_contract(self.symbol) # Craft the transaction arguments for the transfer operation, then broadcast it and get the result tx_args = {"from": from_address, "to": address, "quantity": f"{amount:.4f} {sym}", "memo": memo} tfr = self.build_tx("transfer", contract, from_address, tx_args) # Some of the important data, e.g. how much was actually sent, is buried in the processed>action_traces tx_output = tfr['processed']['action_traces'][0]['act']['data'] tx_amt_final = Decimal(tx_output['quantity'].split()[0]) return { 'txid': tfr['transaction_id'], 'coin': self.orig_symbol, 'amount': tx_amt_final, 'fee': Decimal(0), 'from': from_address, 'send_type': 'send' }
def get_rpc(self, symbol): """ Returns a Steem instance for querying data and sending TXs. By default, uses the Beem shared_steem_instance. If a custom RPC list is specified in the Coin "custom json" settings, a new instance will be returned with the RPCs specified in the json. :param symbol: Coin symbol to get Beem RPC instance for :return beem.steem.Steem: An instance of :class:`beem.steem.Steem` for querying """ rpc_list = self.settings[symbol]['json'].get('rpcs') return self.rpc if empty(rpc_list, itr=True) else Steem(node=rpc_list)
def __init__(self, symbol: str): super().__init__(symbol) settings = self.coin.settings['json'] rpcs = settings.get('rpcs') # If you've specified custom RPC nodes in the custom JSON, make a new instance with those # Otherwise, use the global shared_steem_instance. self.rpc = shared_steem_instance() if empty(rpcs, itr=True) else Steem( rpcs) # type: Steem self.rpc.set_password_storage(settings.get('pass_store', 'environment')) # Internal storage variables for the properties ``asset`` and ``precisions`` self._asset = self._precision = None
def convert_deposit(self, deposit: Deposit, dry=False): """ Takes a Deposit in the 'mapped' state (has been through detect_deposit), and attempts to convert it to the destination coin. :param Deposit deposit: A :class:`payments.models.Deposit` object to convert :raises ConvertError: Raised when a serious error occurs that generally isn't the sender's fault. :raises ConvertInvalid: Raised when a Deposit fails validation, i.e. the sender ignored our instructions. """ d = deposit if d.status != 'mapped': raise ConvertError( "Deposit is not in 'mapped' state during convert_deposit... Something is very wrong!" ) try: if empty(d.convert_to) or empty(d.convert_dest_address): raise ConvertError( 'Deposit "convert_to" or "convert_dest_addr" is empty... Cannot convert!' ) pair = CoinPair.objects.get(from_coin=d.coin, to_coin=d.convert_to) log.debug('Converting deposit ID %s from %s to %s, coin pair: %s', d.id, d.coin, d.convert_to, pair) # Convert() will send the coins, update the Deposit, and create the Conversion object in the DB, # as well as some additional validation such as balance checks. if dry: log.debug( f"DRT RUN: Would run ConvertCore.convert({d}, {pair}, {d.convert_dest_address}, " f"dest_memo='{d.convert_dest_memo}')") return True else: return ConvertCore.convert(d, pair, d.convert_dest_address, dest_memo=d.convert_dest_memo) except (CoinPair.DoesNotExist, Coin.DoesNotExist): raise ConvertInvalid('Deposit is for non-existent coin pair')
def __init__(self, symbol: str): super().__init__(symbol) settings = self.coin.settings['json'] rpcs = settings.get('rpcs') # If you've specified custom RPC nodes in the custom JSON, make a new instance with those # Otherwise, use the global shared_steem_instance. self.rpc = shared_steem_instance() if empty(rpcs, itr=True) else Steem( rpcs) # type: Steem self.rpc.set_password_storage(settings.get('pass_store', 'environment')) # For easy reference, the Beem asset object, and precision self.asset = asset = Asset(self.symbol) self.precision = int(asset.precision)
def save(self, *args, **kwargs): """ To ensure that private keys can only be entered / updated from the admin panel and not viewed, we encrypt them with AES-128 when saving. To avoid encrypting an already encrypted key, we only encrypt the key if we're sure it's not encrypted already. :raises EncryptionError: Something went wrong while encrypting the key :raises EncryptKeyMissing: The key ``settings.ENCRYPT_KEY`` is not set or is not a valid encryption key. """ # If the private_key isn't already encrypted, then encrypt it with AES-128 before saving it to the DB pk = self.private_key if not empty(pk) and not is_encrypted(pk): self.private_key = encrypt_str(pk) return super(CryptoKeyPair, self).save(*args, **kwargs)
def health(self) -> Tuple[str, tuple, tuple]: """ Return health data for the passed symbol. Health data will include: Symbol, Status, Current Block, Node Version, Wallet Balance, and number of p2p connections (all as strings) :return tuple health_data: (manager_name:str, headings:list/tuple, health_data:list/tuple,) """ headers = ('Symbol', 'Status', 'API Node', 'Token Name', 'Issuer', 'Precision', 'Our Account', 'Our Balance') class_name = type(self).__name__ api_node = token_name = issuer = precision = our_account = balance = '' status = 'Okay' try: rpc = self.eng_rpc api_node = rpc.rpc.url our_account = self.coin.our_account if not rpc.account_exists(our_account): status = 'Account {} not found'.format(our_account) tk = rpc.get_token(self.symbol) if empty(tk, itr=True): raise exceptions.TokenNotFound('Token data was empty') tk = dict(tk) issuer = tk.get('issuer', 'ERROR GETTING ISSUER') token_name = tk.get('name', 'ERROR GETTING NAME') precision = str(tk.get('precision', 'ERROR GETTING PRECISION')) balance = self.balance(our_account) balance = ('{0:,.' + str(tk['precision']) + 'f}').format(balance) except exceptions.TokenNotFound: status = 'ERROR' token_name = '<b style="color: red">Token does not exist...</b>' except: status = 'ERROR' log.exception('Exception during %s.health for symbol %s', class_name, self.symbol) if status == 'Okay': status = '<b style="color: green">{}</b>'.format(status) else: status = '<b style="color: red">{}</b>'.format(status) data = (self.symbol, status, api_node, token_name, issuer, precision, our_account, balance) return class_name, headers, data
def handle(self, *args, **options): coins = self.coins if not empty(options['coins']): coins = self.coins.filter( symbol__in=[c.upper() for c in options['coins'].split(',')]) log.info( 'Option --coins was specified. Only loading TXs for coins: %s', [str(c) for c in coins]) for c in coins: try: self.load_txs(c.symbol) except: log.exception( 'Error loading transactions for coin %s. Moving onto the next coin.', c)
def load(self, tx_count=1000): """ Prepares the loader by disabling any symbols / coin objects that don't have an `our_account` set, or don't have a `contract` set in either :py:attr:`models.Coin.settings_json` or :py:attr:`.default_contracts` :param tx_count: Amount of transactions to load per account, most recent first :return: None """ 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 loadsettings = dict(self.settings) # Loop over each Coin we're responsible for, make sure every EOS token has both an `our_account` and # a contract set (either in Coin.setting_json or EOSMixin.default_contracts). Disable any that don't. for symbol, coin in self.coins.items(): safe = False # Assume a token is not valid by default. symbol = symbol.upper() try: if empty(coin.our_account): 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 {chain} token "{coin}". Reason: {type(e)} - {str(e)}' ) else: 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 %s symbols that were not disabled: %s', __name__, self.symbols) self.loaded = True
def get_actions(self, account: str, count=100) -> List[dict]: """ Loads EOS transactions for a given account, and caches them per account to avoid constant queries. :param account: The EOS account to load transactions for :param count: Amount of transactions to load :return list transactions: A list of EOS transactions as dict's """ cache_key = f'{self.chain}_actions:{account}' actions = cache.get(cache_key) if empty(actions): 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'] cache.set(cache_key, actions, timeout=60) return actions
def v2_get_actions(self, account: str, count=100) -> List[dict]: """ Loads EOS transactions for a given account, and caches them per account to avoid constant queries. Uses v2 account history API :param account: The EOS account to load transactions for :param count: Amount of transactions to load :return list transactions: A list of EOS transactions as dict's """ cache_key = f'{self.chain}_v2_actions:{account}' actions = cache.get(cache_key) if empty(actions): log.info('Loading %s v2 actions for %s from node %s', self.chain.upper(), account, self.url) url = f'{self.url}/v2/history/get_actions?limit={count}&account={account}' req = requests.get(url) actions = req.json()['actions'] cache.set(cache_key, actions, timeout=60) return actions
def _clean_tx(self, tx, symbol, address): """Filters an individual transaction. See :meth:`.clean_txs` for info""" need_confs = self.settings[symbol]['confirms_needed'] use_trusted = self.settings[symbol]['use_trusted'] txid = tx.get('txid', None) category = tx.get('category', 'UNKNOWN') trust = tx.get('trusted', False) amt = tx['amount'] # To avoid issues with floats, we convert the amount to a string with 8DP if type(amt) == float: amt = '{0:.8f}'.format(amt) log.debug('Filtering/cleaning transaction, Cat: %s, Amt: %s, TXID: %s', category, amt, txid) if category != 'receive': return None # Ignore non-receive transactions if 'generated' in tx and tx['generated'] in [True, 'true', 1]: return None # Ignore mining transactions # Filter by receiving address if needed if not empty(address) and tx['address'] != address: return None # If a TX has less confirmations than needed, check if we can trust unconfirmed TXs. # If not, we can't accept this TX. if int(tx['confirmations']) < need_confs: if not use_trusted or trust not in [True, 'true', 1]: log.debug('Got %s transaction %s, but only has %d confs, needs %d', symbol, txid, tx['confirmations'], need_confs) return None d = datetime.utcfromtimestamp(tx['time']) d = timezone.make_aware(d, pytz.UTC) return dict( txid=txid, coin=self.coins[symbol].symbol, vout=int(tx['vout']), tx_timestamp=d, address=tx['address'], amount=Decimal(amt) )
def validate_deposit(deposit: Deposit) -> Tuple[str, CoinPair, str]: """ Validates and identifies the destination CoinPair and account/address of a given Deposit. Returns a tuple containing the destination address/account, the CoinPair for conversion, and the destination memo (if it has one, otherwise it will be blank or None). :param Deposit deposit: The Deposit object to validate and return destination details for :raises ConvertError: Raised when a serious error occurs that generally isn't the sender's fault. :raises ConvertInvalid: Raised when a Deposit fails validation, i.e. the sender ignored our instructions. :raises Coin.DoesNotExist: Destination coin doesn't exist :raises CoinPair.DoesNotExist: Detected conversion pair does not exist :return tuple: (dest_address: str, coin_pair: CoinPair, dest_memo: str) """ d = deposit # There's a OneToOne relation between a deposit and a conversion # If we try to insert a conversion when a deposit already has one, it'll throw an error... if Conversion.objects.filter(deposit=d).count() > 0: log.warning( 'Error: A conversion already exists for deposit "%s". Aborting conversion.', d) raise ConvertError( 'A Conversion object already exists for this deposit. An admin should investigate the logs, ' 'make sure no coins were sent in the previous conversion attempt, then remove the related ' 'conversion from the DB.') # If you're not supposed to be able to convert from this coin, then we can't process it. if d.coin.pairs_from.count() < 1: raise ConvertInvalid('No coin pairs with from_coin = {}'.format( d.coin.symbol)) memo = d.memo.strip() if d.memo is not None else None # If a deposit has a memo, but not a crypto address, then we parse the memo if not empty(memo) and empty(d.address): log.debug( 'Deposit ID %s has a memo, attempting to detect destination pair', d.id) m = memo.split() if len(m) < 2: log.warning( 'Marking "inv" - memo split by spaces has less than 2 items... "%s"', d) raise ConvertInvalid( 'Memo is not valid - splitting by whitespace resulted in <2 items.' ) symbol, address = ( m[0].upper(), m[1] ) # First item is dest symbol, second is address/account dest_memo = ' '.join(m[2:]) if len( m ) >= 3 else '' # 3+ items means there's a destination memo at the end pair = CoinPair.objects.get( from_coin=d.coin, to_coin=Coin.objects.get(symbol=symbol)) return address, pair, dest_memo if not empty(d.address): a_map = AddressAccountMap.objects.filter(deposit_coin=d.coin, deposit_address=d.address) a_map = a_map.filter( deposit_memo=memo) if not empty(memo) else a_map a_map = a_map.first() if a_map is None: raise ConvertInvalid( "Deposit address {} has no known coin destination mapped to it." .format(d.address)) pair = CoinPair.objects.get(from_coin=d.coin, to_coin=a_map.destination_coin) address = a_map.destination_address dest_memo = a_map.destination_memo return address, pair, dest_memo # If there's no address, and no memo, we have no idea what to do with this deposit raise ConvertInvalid( 'No deposit address nor memo - unable to route this deposit anywhere...' )
def convert(deposit: Deposit, pair: CoinPair, address: str, dest_memo: str = None): """ After a Deposit has passed the validation checks of :py:meth:`.detect_deposit` , this method loads the appropriate coin handler, calculates fees, generates a memo, and sends the exchanged coins to their destination. :param Deposit deposit: The deposit object to be converted :param CoinPair pair: A CoinPair object for getting the exchange rate + destination coin :param str address: The destination crypto address, or account :param str dest_memo: Optionally specify a memo for the coins to be sent with :raises ConvertError: Raised when a serious error occurs that generally isn't the sender's fault. :raises ConvertInvalid: Raised when a Deposit fails validation, i.e. the sender ignored our instructions. :return Conversion c: The inserted conversion object after a successful transfer :return None None: Something is wrong with the coin handler, try again later, do not set deposit to error. """ # f/tcoin are the actual Coin objects fcoin = pair.from_coin tcoin = pair.to_coin # dest/src are string symbols dest_coin = tcoin.symbol.upper() src_coin = fcoin.symbol.upper() mgr = get_manager(dest_coin) if empty(dest_memo): dest_memo = 'Token Conversion' if not empty(deposit.address): dest_memo += ' via {} deposit address {}'.format( src_coin, deposit.address) if not empty(deposit.from_account): dest_memo += ' from {} account {}'.format( src_coin, deposit.from_account) send_amount, ex_fee = ConvertCore.amount_converted( deposit.amount, pair.exchange_rate, settings.EX_FEE) log.info('Attempting to send %f %s to address/account %s', send_amount, dest_coin, address) try: if not mgr.health_test(): log.warning( "Coin %s health test has reported that it's down. Will try again later...", tcoin) deposit.last_convert_attempt = timezone.now() deposit.save() return None metadata = dict(deposit=deposit, pair=pair, action="convert") if tcoin.can_issue: s = mgr.send_or_issue(amount=send_amount, address=address, memo=dest_memo, trigger_data=metadata) else: s = mgr.send(amount=send_amount, address=address, memo=dest_memo, trigger_data=metadata) log.info('Successfully sent %f %s to address/account %s', send_amount, dest_coin, address) deposit.status = 'conv' deposit.convert_to = tcoin deposit.processed_at = timezone.now() deposit.save() c = Conversion(deposit=deposit, from_coin=fcoin, to_coin=tcoin, from_address=s.get('from', None), to_address=address, to_memo=dest_memo, to_amount=s.get('amount', deposit.amount), to_txid=s.get('txid', None), tx_fee=s.get('fee', Decimal(0)), ex_fee=ex_fee) c.save() log.info('Successfully stored Conversion. Conversion ID is %s', c.id) return c except AccountNotFound: raise ConvertInvalid( 'Destination address "{}" appears to be invalid. Exc: AccountNotFound' .format(address)) except NotEnoughBalance: log.error( 'Not enough balance to send %f %s. Will try again later...', send_amount, dest_coin) try: deposit.last_convert_attempt = timezone.now() deposit.save() ConvertCore.notify_low_bal(pair=pair, send_amount=send_amount, balance=mgr.balance(), deposit_addr=mgr.get_deposit()[1]) except: log.exception( 'Failed to send ADMINS email notifications for low balance of coin %s', dest_coin) return None
def send(self, amount, address, memo=None, from_address=None) -> dict: """ Send tokens to a given address/account, optionally specifying a memo. The Bitshares network transaction fee will be subtracted from the amount before sending. There must be a valid :py:class:`models.CryptoKeyPair` in the database for both 'active' and 'memo' keys for the from_address account, or an AuthorityMissing exception will be thrown. Example - send 1.23 BUILDTEAM from @someguy123 to @privex with memo 'hello' >>> s = BitsharesManager('BUILDTEAM') >>> s.send(from_address='someguy123', address='privex', amount=Decimal('1.23'), memo='hello') :param Decimal amount: Amount of tokens to send, as a Decimal() :param address: Account to send the tokens to :param from_address: Account to send the tokens from :param memo: Memo to send tokens with :raises AttributeError: When both `from_address` and `self.coin.our_account` are blank. :raises ArithmeticError: When the amount is lower than the lowest amount allowed by the token's precision (after subtracting the network transaction fee) :raises AuthorityMissing: Cannot send because we don't have authority to (missing key etc.) :raises AccountNotFound: The requested account/address doesn't exist :raises TokenNotFound: When the requested token `symbol` does not exist :raises NotEnoughBalance: The account `from_address` does not have enough balance to send this amount. :return dict: Result Information Format:: { txid:str - Transaction ID - None if not known, coin:str - Symbol that was sent, amount:Decimal - The amount that was sent (after fees), fee:Decimal - TX Fee that was taken from the amount, from:str - The account/address the coins were sent from, send_type:str - Should be statically set to "send" } """ # Try from_address first. If that's empty, try using self.coin.our_account. If both are empty, abort. if empty(from_address): if empty(self.coin.our_account): raise AttributeError( "Both 'from_address' and 'coin.our_account' are empty. Cannot send." ) from_address = self.coin.our_account # make sure we have the necessary private keys loaded (memo key for encrypting memo, active key for sending coins) self.set_wallet_keys(from_address, ['memo', 'active']) asset_obj = self.get_asset_obj(self.symbol) if asset_obj is None: raise exceptions.TokenNotFound( f'Failed to send because {self.symbol} is an invalid token symbol.' ) # trim input amount to the token's precision just to be safe str_amount = ('{0:.' + str(asset_obj['precision']) + 'f}').format(amount) amount = Decimal(str_amount) if not self.is_amount_above_minimum(amount, asset_obj['precision']): raise ArithmeticError( f'Failed to send because {amount} is less than the minimum amount allowed for {self.symbol} tokens.' ) from_account = self.get_account_obj(from_address) if from_account is None: raise exceptions.AccountNotFound( f'Failed to send because source account {from_address} could not be found.' ) to_account = self.get_account_obj(address) if to_account is None: raise exceptions.AccountNotFound( f'Failed to send because destination account {address} could not be found.' ) # verify from_account balance is sufficient for the transaction from_account_balance = self.get_decimal_from_amount( from_account.balance(self.symbol)) if from_account_balance < amount: raise exceptions.NotEnoughBalance( f'Failed to send because source account {from_address} balance {from_account_balance} {self.symbol} is less than amount to send ({amount} {self.symbol}).' ) amount_obj = Amount(str_amount, self.symbol, blockchain_instance=self.bitshares) try: if memo is None: memo = '' memo_obj = Memo(from_account=from_account, to_account=to_account, blockchain_instance=self.bitshares) encrypted_memo = memo_obj.encrypt(memo) # build preliminary transaction object, without network transaction fee op = operations.Transfer( **{ 'fee': { 'amount': 0, 'asset_id': amount_obj.asset['id'] }, 'from': from_account['id'], 'to': to_account['id'], 'amount': { 'amount': int(amount_obj), 'asset_id': amount_obj.asset['id'] }, 'memo': encrypted_memo, 'prefix': self.bitshares.prefix, }) # calculate how much the transaction fee is - rather clunky method here but it works ops = [self.bitshares.txbuffer.operation_class(op)] ops_with_fees = self.bitshares.txbuffer.add_required_fees( ops, asset_id=amount_obj.asset['id']) raw_fee_amount = Decimal( str(ops_with_fees[0][1].data['fee']['amount'])) fee_amount_str = '{0:f}'.format( raw_fee_amount / (10**amount_obj.asset['precision'])) fee_amount = Amount(fee_amount_str, self.symbol, blockchain_instance=self.bitshares) amount_obj = amount_obj - fee_amount # verify the amount still makes sense after subtracting the transaction fee if int(amount_obj) < 1: raise ArithmeticError( f'Failed to send because {amount} is less than the network transaction fee of {fee_amount_str} {self.symbol} tokens.' ) # correct the transaction object to take into account the transaction fee adj_op = operations.Transfer( **{ 'fee': { 'amount': int(fee_amount), 'asset_id': amount_obj.asset['id'] }, 'from': from_account['id'], 'to': to_account['id'], 'amount': { 'amount': int(amount_obj), 'asset_id': amount_obj.asset['id'] }, 'memo': encrypted_memo, 'prefix': self.bitshares.prefix, }) log.debug( 'doing Bitshares transaction - from_address[%s], address[%s], amount[%s %s], fee_amount[%s], amount_obj[%s], memo[%s]', from_address, address, str_amount, self.symbol, fee_amount, amount_obj, memo) # and finally, do the op! self.bitshares.finalizeOp(adj_op, from_address, "active", fee_asset=amount_obj.asset['id']) result = self.bitshares.broadcast() except KeyNotFound as e: raise exceptions.AuthorityMissing(str(e)) return { 'txid': None, # transaction ID is not readily available from the Bitshares API 'coin': self.orig_symbol, 'amount': self.get_decimal_from_amount(amount_obj), 'fee': self.get_decimal_from_amount(fee_amount), 'from': from_address, 'send_type': 'send' }
def send(self, amount: Decimal, address: str, from_address: str = None, memo: str = None) -> dict: """ Send a supported currency to a given address/account, optionally specifying a memo if supported Example - send 1.23 STEEM from @someguy123 to @privex with memo 'hello' >>> s = SteemManager('STEEM') >>> s.send(from_address='someguy123', address='privex', amount=Decimal('1.23'), memo='hello') :param Decimal amount: Amount of currency to send, as a Decimal() :param address: Account to send the currency to :param from_address: Account to send the currency from :param memo: Memo to send currency with :raises AttributeError: When both `from_address` and `self.coin.our_account` are blank. :raises ArithmeticError: When the amount is lower than the lowest amount allowed by the asset's precision :raises AuthorityMissing: Cannot send because we don't have authority to (missing key etc.) :raises AccountNotFound: The requested account doesn't exist :raises NotEnoughBalance: The account `from_address` does not have enough balance to send this amount. :return dict: Result Information Format:: { txid:str - Transaction ID - None if not known, coin:str - Symbol that was sent, amount:Decimal - The amount that was sent (after fees), fee:Decimal - TX Fee that was taken from the amount, from:str - The account/address the coins were sent from, send_type:str - Should be statically set to "send" } """ # Try from_address first. If that's empty, try using self.coin.our_account. If both are empty, abort. if empty(from_address): if empty(self.coin.our_account): raise AttributeError( "Both 'from_address' and 'coin.our_account' are empty. Cannot send." ) from_address = self.coin.our_account prec = self.precision sym = self.symbol memo = "" if empty(memo) else memo try: if type(amount) != Decimal: if type(amount) == float: amount = ('{0:.' + str(self.precision) + 'f}').format(amount) amount = Decimal(amount) ### # Various sanity checks, e.g. checking amount is valid, to/from account are valid, we have # enough balance to send this amt, etc. ### if amount < Decimal(pow(10, -prec)): log.warning( 'Amount %s was passed, but is lower than precision for %s', amount, sym) raise ArithmeticError( 'Amount {} is lower than token {}s precision of {} DP'. format(amount, sym, prec)) acc = Account(from_address, steem_instance=self.rpc) if not self.address_valid(address): raise exceptions.AccountNotFound( 'Destination account does not exist') if not self.address_valid(from_address): raise exceptions.AccountNotFound('From account does not exist') bal = self.balance(from_address) if bal < amount: raise exceptions.NotEnoughBalance( 'Account {} has balance {} but needs {} to send this tx'. format(from_address, bal, amount)) ### # Broadcast the transfer transaction on the network, and return the necessary data ### log.debug('Sending %f %s to @%s', amount, sym, address) tfr = acc.transfer(address, amount, sym, memo) # Beem's finalizeOp doesn't include TXID, so we try to find the TX on the blockchain after broadcast tx = self.find_steem_tx(tfr) log.debug('Success? TX Data - Transfer: %s Lookup TX: %s', tfr, tx) # Return TX data compatible with BaseManager standard return { # There's a risk we can't get the TXID, and so we fall back to None. 'txid': tx.get('transaction_id', None), 'coin': sym, 'amount': amount, 'fee': Decimal(0), 'from': from_address, 'send_type': 'send' } except MissingKeyError: raise exceptions.AuthorityMissing( 'Missing active key for sending account {}'.format( from_address))