from abc import ABC import orjson from core.clients.platforms.agora import AgoraClient from core.lib import 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 platforms = self.instance.platforms for transaction in transactions: transaction_id = transaction["transaction_id"] tx_obj = self.instance.get_transaction( account_id, transaction_id, ) if tx_obj is None: tx_cast = { "transaction_id": transaction_id, "recipient": transaction["creditorName"], "sender": transaction["debtorName"], "amount": transaction["amount"], "currency": transaction["currency"], "note": transaction["reference"], } tx_obj = self.instance.add_transaction( account_id, tx_cast, ) # New transaction await notify.sendmsg( f"New transaction: {orjson.dumps(tx_cast)}", title="New transaction" ) await self.transaction(platforms, tx_obj) else: # Transaction exists continue # 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, tx_obj): """ 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 = tx_obj.transaction_id if tx_obj.amount is None: return False if tx_obj.currency is None: return False amount = tx_obj.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, platform, 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 = platform.references # Get all parts of the given reference split that match the existing references # stored_trade_reference = set(existing_refs).intersection(set(ref_split)) stored_trade_reference = [x for x in existing_refs if x in 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, platform, amount, currency, reference): amount_usd = await money.to_usd(amount, currency) # Amount is reliable here as it is checked by find_trade, # so no need for stored_trade["amount"] if amount_usd > platform.no_reference_amount_check_max_usd: 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 def find_trade(self, platform, txid, currency, amount): """ Get a trade reference that matches the given currency and amount. Only works if there is one result. :param txid: Sink transaction ID :param currency: currency :param amount: amount :type txid: string :type currency: string :type amount: int :return: matching trade object or False :rtype: dict or bool """ refs = platform.references matching_refs = [] # TODO: use get_ref_map in this function instead of calling get_ref multiple # times for ref in refs: stored_trade = platform.get_trade_by_reference(ref) if stored_trade.currency == currency and stored_trade.amount_fiat == amount: matching_refs.append(stored_trade) if len(matching_refs) != 1: log.error( f"Find trade returned multiple results for TXID {txid}: {matching_refs}" ) return False return matching_refs[0] async def amount_currency_lookup(self, platform, amount, currency, txid, ref): 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(platform, amount, currency, ref): return False stored_trade = self.find_trade(platform, 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 return stored_trade async def normal_lookup( self, platform, stored_trade_reference, reference, currency, amount ): stored_trade = platform.get_trade_by_reference(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, 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, 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 money.get_acceptable_margins( platform, currency, stored_trade.amount_fiat ) log.info( ( f"Amount does not match exactly, trying with margins: min: {min_amount}" f" / max: {max_amount}" ) ) 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_fiat} " f"(min: {min_amount} / max: {max_amount}" ) await notify.sendmsg(self.instance.user, message, title=title) return False return True async def transaction(self, platforms, tx_obj): """ 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(tx_obj) if not valid: return False txid = tx_obj.transaction_id amount = tx_obj.amount currency = tx_obj.currency reference = tx_obj.note # reference = self.extract_reference(data) # sender = tx_obj.sender log.info(f"Transaction processed: {tx_obj}") await notify.sendmsg( self.instance.user, (f"Transaction: {txid} {amount}{currency}: {reference}"), title="Incoming transaction", ) for platform in platforms: stored_trade_reference = await self.reference_partial_check( platform, reference, txid, currency, amount ) if stored_trade_reference is False: # can be None though continue stored_trade = False looked_up_without_reference = False # Normal implementation for when we have a reference if stored_trade_reference: stored_trade = await self.normal_lookup( platform, 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 = await self.amount_currency_lookup( platform, amount, currency, txid, reference ) if stored_trade is False: continue if stored_trade: # Note that we have looked it up without reference so we don't # use +- below # This might be redundant given the checks in find_trade, # but better safe than sorry! looked_up_without_reference = True else: continue else: # Stored trade reference is none, the checks below will do nothing continue # Make sure it was sent in the expected currency if not await self.currency_check(currency, stored_trade): continue # Make sure the expected amount was sent if not stored_trade.amount_fiat == amount: if looked_up_without_reference: continue if not await self.alt_amount_check( platform, amount, currency, stored_trade ): continue # 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}") instance = await AgoraClient(platform) rtrn = await instance.release_map_trade(stored_trade, tx_obj) # if trade_released: # self.ux.notify.notify_complete_trade(amount, currency) # else: # log.error(f"Cannot release trade {reference}.") # return # rtrn = await platform.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)