from abc import ABC import orjson from core.lib import db, notify # from core.lib.money import money from core.util import logs log = logs.get_logger("aggregator") class AggregatorClient(ABC): def store_account_info(self, account_infos): # account_infos = { # bank: accounts # for bank, accounts in account_info.items() # for account in accounts # #if account["account_id"] in self.banks # } # For each bank for bank, accounts in account_infos.items(): # Iterate the accounts for index, account in enumerate(list(accounts)): if "account_number" not in account: account_infos[bank][index]["account_number"] = {} fields = ["sort_code", "number", "iban"] for field in fields: if field in account: account_infos[bank][index]["account_number"][ field ] = account[field] del account_infos[bank][index][field] # if len(account["account_number"]) == 1: # account_infos[bank].remove(account) currencies = [ account["currency"] for bank, accounts in account_infos.items() for account in accounts ] for bank, accounts in account_infos.items(): if not self.instance.account_info: self.instance.account_info = {} self.instance.account_info[bank] = [] for account in accounts: self.instance.account_info[bank].append(account) # self.account_info = account_infos self.currencies = currencies self.instance.currencies = currencies self.instance.save() async def process_transactions(self, account_id, transactions): if not transactions: return False transaction_ids = [x["transaction_id"] for x in transactions] new_key_name = f"new.transactions.{self.instance.id}.{self.name}.{account_id}" old_key_name = f"transactions.{self.instance.id}.{self.name}.{account_id}" # for transaction_id in transaction_ids: if not transaction_ids: return await db.r.sadd(new_key_name, *transaction_ids) difference = list(await db.r.sdiff(new_key_name, old_key_name)) difference = db.convert(difference) new_transactions = [ x for x in transactions if x["transaction_id"] in difference ] # Rename the new key to the old key so we can run the diff again await db.r.rename(new_key_name, old_key_name) for transaction in new_transactions: transaction["subclass"] = self.name # self.tx.transaction(transaction) def valid_transaction(self, data): """ Determine if a given transaction object is valid. :param data: a transaction cast :type data: dict :return: whether the transaction is valid :rtype: bool """ txid = data["transaction_id"] if "amount" not in data: return False if "currency" not in data: return False amount = data["amount"] if amount <= 0: log.info(f"Ignoring transaction with negative/zero amount: {txid}") return False return True def extract_reference(self, data): """ Extract a reference from the transaction cast. :param data: a transaction cast :type data: dict :return: the extracted reference or not_set :rtype: str """ if "reference" in data: return data["reference"] elif "meta" in data: if "provider_reference" in data["meta"]: return data["meta"]["provider_reference"] return "not_set" def extract_sender(self, data): """ Extract a sender name from the transaction cast. :param data: a transaction cast :type data: dict :return: the sender name or not_set :rtype: str """ if "debtorName" in data: return data["debtorName"] elif "meta" in data: if "debtor_account_name" in data["meta"]: return data["meta"]["debtor_account_name"] elif " " in data["reference"]: refsplit = data["reference"].split(" ") if not len(refsplit) == 2: log.error(f"Sender cannot be extracted: {data}") return "not_set" realname, part2 = data["reference"].split(" ") return realname return "not_set" async def reference_partial_check(self, reference, txid, currency, amount): """ Perform a partial check by intersecting all parts of the split of the reference against the existing references, and returning a set of the matches. :param reference: the reference to check :type reference: str :return: matching trade ID string :rtype: str """ # Partial reference implementation # Account for silly people not removing the default string # Split the reference into parts ref_split = reference.split(" ") # Get all existing references existing_refs = await db.get_refs() # Get all parts of the given reference split that match the existing references stored_trade_reference = set(existing_refs).intersection(set(ref_split)) if len(stored_trade_reference) > 1: message = ( f"Multiple references valid for TXID {txid}: {reference}" f"Currency: {currency} | Amount: {amount}" ) title = "Error: multiple references valid" await notify.sendmsg(self.instance.user, message, title=title) return False if len(stored_trade_reference) == 0: return None return stored_trade_reference.pop() # TODO: pass platform here # async def can_alt_lookup(self, amount, currency, reference): # amount_usd = self.money.to_usd(amount, currency) # # Amount is reliable here as it is checked by find_trade, # # so no need for stored_trade["amount"] # if float(amount_usd) > float(settings.Agora.AcceptableAltLookupUSD): # message = ( # f"Amount exceeds max for {reference}" # f"Currency: {currency} | Amount: {amount}" # ) # title = "Amount exceeds max for {reference}" # await notify.sendmsg(self.instance.user, message, title=title) # return False # return True async def amount_currency_lookup(self, amount, currency, txid, reference): log.info(f"No reference in DB refs for {reference}") self.irc.sendmsg(f"No reference in DB refs for {reference}") # Try checking just amount and currency, as some people # (usually people buying small amounts) # are unable to put in a reference properly. log.info(f"Checking against amount and currency for TXID {txid}") self.irc.sendmsg(f"Checking against amount and currency for TXID {txid}") title = f"Checking against amount and currency for TXID {txid}" message = ( f"Checking against amount and currency for TXID {txid}" f"Currency: {currency} | Amount: {amount}" ) await notify.sendmsg(self.instance.user, message, title=title) if not await self.can_alt_lookup(amount, currency, reference): return False stored_trade = await self.find_trade(txid, currency, amount) if not stored_trade: title = f"Failed to get reference by amount and currency: {txid}" message = ( f"Failed to get reference by amount and currency: {txid}" f"Currency: {currency} | Amount: {amount}" ) await notify.sendmsg(self.instance.user, message, title=title) return None stored_trade["amount"] = float(stored_trade["amount"]) # convert to float return stored_trade async def normal_lookup(self, stored_trade_reference, reference, currency, amount): stored_trade = await db.get_ref(stored_trade_reference) if not stored_trade: title = f"No reference in DB for {reference}" message = ( f"No reference in DB for {reference}" f"Currency: {currency} | Amount: {amount}" ) await notify.sendmsg(self.instance.user, message, title=title) return False stored_trade["amount"] = float(stored_trade["amount"]) # convert to float return stored_trade async def currency_check(self, currency, amount, reference, stored_trade): if not stored_trade["currency"] == currency: title = "Currency mismatch" message = ( f"Currency mismatch, Agora: {stored_trade['currency']} " f"/ Sink: {currency}" ) await notify.sendmsg(self.instance.user, message, title=title) return False return True async def alt_amount_check( self, platform, amount, currency, reference, stored_trade ): # If the amount does not match exactly, get the min and max values for our # given acceptable margins for trades min_amount, max_amount = await self.money.get_acceptable_margins( platform, currency, stored_trade["amount"] ) log.info( ( f"Amount does not match exactly, trying with margins: min: {min_amount}" f" / max: {max_amount}" ) ) self.irc.sendmsg() title = "Amount does not match exactly" message = ( f"Amount does not match exactly, trying with margins: min: " f"{min_amount} / max: {max_amount}" ) await notify.sendmsg(self.instance.user, message, title=title) if not min_amount < amount < max_amount: title = "Amount mismatch - not in margins" message = ( f"Amount mismatch - not in margins: {stored_trade['amount']} " f"(min: {min_amount} / max: {max_amount}" ) await notify.sendmsg(self.instance.user, message, title=title) return False return True async def transaction(self, data): """ Store details of transaction and post notifications to IRC. Matches it up with data stored in Redis to attempt to reconcile with an Agora trade. :param data: details of transaction :type data: dict """ valid = self.valid_transaction(data) if not valid: return False ts = data["timestamp"] txid = data["transaction_id"] amount = float(data["amount"]) currency = data["currency"] reference = self.extract_reference(data) sender = self.extract_sender(data) subclass = data["subclass"] to_store = { "subclass": subclass, "ts": ts, "txid": txid, "reference": reference, "amount": amount, "currency": currency, "sender": sender, } db.r.hmset(f"tx.{txid}", to_store) log.info(f"Transaction processed: {orjson.dumps(to_store, indent=2)}") self.irc.sendmsg( ( f"AUTO Incoming transaction on {subclass}: {txid} {amount}{currency} " f"({reference})" ) ) stored_trade_reference = self.reference_partial_check( reference, txid, currency, amount ) if stored_trade_reference is False: # can be None though return stored_trade = False looked_up_without_reference = False # Normal implementation for when we have a reference if stored_trade_reference: stored_trade = self.normal_lookup( stored_trade_reference, reference, currency, amount ) # if not stored_trade: # return # Amount/currency lookup implementation for when we have no reference else: if not stored_trade: # check we don't overwrite the lookup above stored_trade = self.amount_currency_lookup( amount, currency, txid, reference ) if stored_trade is False: return if stored_trade: # Note that we have looked it up without reference so we don't use # +- below # This might be redundant given the amount checks in find_trade, # but better safe than sorry! looked_up_without_reference = True else: return else: # Stored trade reference is none, the checks below will do nothing return # Make sure it was sent in the expected currency if not self.currency_check(currency, amount, reference, stored_trade): return # Make sure the expected amount was sent if not stored_trade["amount"] == amount: if looked_up_without_reference: return platform = stored_trade["subclass"] if not self.alt_amount_check( platform, amount, currency, reference, stored_trade ): return platform = stored_trade["subclass"] # platform_buyer = stored_trade["buyer"] # Check sender - we don't do anything with this yet # sender_valid = antifraud.check_valid_sender( # reference, platform, sender, platform_buyer # ) # log.info(f"Trade {reference} buyer {platform_buyer} valid: {sender_valid}") # trade_released = self.release_map_trade(reference, txid) # if trade_released: # self.ux.notify.notify_complete_trade(amount, currency) # else: # log.error(f"Cannot release trade {reference}.") # return rtrn = await self.release_funds(stored_trade["id"], stored_trade["reference"]) if rtrn: title = "Trade complete" message = f"Trade complete: {amount}{currency}" await notify.sendmsg(self.instance.user, message, title=title)