# Twisted/Klein imports # Other library imports from json import dumps from random import choices from string import ascii_uppercase import db import util # Project imports from settings import settings from twisted.internet.defer import inlineCallbacks class Transactions(util.Base): """ Handler class for incoming Revolut transactions. """ 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: self.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: self.log.error(f"Sender cannot be extracted: {data}") return "not_set" realname, part2 = data["reference"].split(" ") return realname return "not_set" 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 = 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: self.log.error(f"Multiple references valid for TXID {txid}: {reference}") self.irc.sendmsg(f"Multiple references valid for TXID {txid}: {reference}") self.ux.notify.notify_tx_lookup_failed( currency, amount, reference, "MULTIPLE_REFS_MATCH" ) return False if len(stored_trade_reference) == 0: return None return stored_trade_reference.pop() 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): self.log.info( "Not checking against amount and currency as amount exceeds MAX" ) self.irc.sendmsg( "Not checking against amount and currency as amount exceeds MAX" ) # Close here if the amount exceeds the allowable limit for no reference self.ux.notify.notify_tx_lookup_failed( currency, amount, reference, "EXCEEDS_MAX" ) return False return True def amount_currency_lookup(self, amount, currency, txid, reference): self.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. self.log.info(f"Checking against amount and currency for TXID {txid}") self.irc.sendmsg(f"Checking against amount and currency for TXID {txid}") if not self.can_alt_lookup(amount, currency, reference): return False stored_trade = self.find_trade(txid, currency, amount) if not stored_trade: self.log.info( f"Failed to get reference by amount and currency: {txid} {currency} {amount}" ) self.irc.sendmsg( f"Failed to get reference by amount and currency: {txid} {currency} {amount}" ) self.ux.notify.notify_tx_lookup_failed( currency, amount, reference, "ALT_LOOKUP_FAILED" ) return None stored_trade["amount"] = float(stored_trade["amount"]) # convert to float return stored_trade def normal_lookup(self, stored_trade_reference, reference, currency, amount): stored_trade = db.get_ref(stored_trade_reference) if not stored_trade: self.log.info(f"No reference in DB for {reference}") self.irc.sendmsg(f"No reference in DB for {reference}") self.ux.notify.notify_tx_lookup_failed( currency, amount, reference, "NOREF", stored_trade_reference ) return False stored_trade["amount"] = float(stored_trade["amount"]) # convert to float return stored_trade def currency_check(self, currency, amount, reference, stored_trade): if not stored_trade["currency"] == currency: self.log.info( f"Currency mismatch, Agora: {stored_trade['currency']} / Sink: {currency}" ) self.irc.sendmsg( f"Currency mismatch, Agora: {stored_trade['currency']} / Sink: {currency}" ) self.ux.notify.notify_tx_lookup_failed( currency, amount, reference, "CURRENCY_MISMATCH", stored_trade["id"], ) return False return True 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 = self.money.get_acceptable_margins( platform, currency, stored_trade["amount"] ) self.log.info( f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}" ) self.irc.sendmsg( f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}" ) if not min_amount < amount < max_amount: self.log.info( "Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}" ) self.irc.sendmsg( f"Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}" ) self.ux.notify.notify_tx_lookup_failed( currency, amount, reference, "AMOUNT_MARGIN_MISMATCH", stored_trade["id"], ) return False return True @inlineCallbacks 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) self.log.info(f"Transaction processed: {dumps(to_store, indent=2)}") self.irc.sendmsg( f"AUTO Incoming transaction on {subclass}: {txid} {amount}{currency} ({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 at all 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 = self.antifraud.check_valid_sender( reference, platform, sender, platform_buyer ) self.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: # self.log.error(f"Cannot release trade {reference}.") # return rtrn = yield self.release_funds(stored_trade["id"], stored_trade["reference"]) if rtrn: self.ux.notify.notify_complete_trade(amount, currency) @inlineCallbacks def release_funds(self, trade_id, reference): stored_trade = db.get_ref(reference) platform = stored_trade["subclass"] logmessage = f"All checks passed, releasing funds for {trade_id} {reference}" self.log.info(logmessage) self.irc.sendmsg(logmessage) if platform == "agora": release = self.agora.release_funds post_message = self.agora.api.contact_message_post elif platform == "lbtc": release = self.lbtc.release_funds post_message = self.lbtc.api.contact_message_post rtrn = yield release(trade_id) if rtrn["message"] == "OK": post_message(trade_id, "Thanks! Releasing now :)") return True else: logmessage = f"Release funds unsuccessful: {rtrn['message']}" self.log.error(logmessage) self.irc.sendmsg(logmessage) self.ux.notify.notify_release_unsuccessful(trade_id) return # Parse the escrow release response message = rtrn["message"] # message_long = rtrn["response"]["data"]["message"] self.irc.sendmsg(f"{dumps(message)}") def release_map_trade(self, reference, tx): """ Map a trade to a transaction and release if no other TX is mapped to the same trade. """ stored_trade = db.get_ref(reference) if not stored_trade: self.log.error(f"Could not get stored trade for {reference}.") return None tx_obj = db.get_tx(tx) if not tx_obj: self.log.error(f"Could not get TX for {tx}.") return None platform = stored_trade["subclass"] platform_buyer = stored_trade["buyer"] bank_sender = tx_obj["sender"] trade_id = stored_trade["id"] is_updated = self.antifraud.update_trade_tx(reference, tx) if is_updated is None: return None elif is_updated is True: # We mapped the trade successfully self.release_funds(trade_id, reference) self.antifraud.add_bank_sender(platform, platform_buyer, bank_sender) return True elif is_updated is False: # Already mapped self.log.error( f"Trade {reference} already has a TX mapped, cannot map {tx}." ) return False def new_trade( self, subclass, asset, trade_id, buyer, currency, amount, amount_crypto, provider, ): """ Called when we have a new trade in Agora. Store details in Redis, generate a reference and optionally let the customer know the reference. """ reference = "".join(choices(ascii_uppercase, k=5)) reference = f"PGN-{reference}" existing_ref = db.r.get(f"trade.{trade_id}.reference") if not existing_ref: to_store = { "id": trade_id, "tx": "", "asset": asset, "buyer": buyer, "currency": currency, "amount": amount, "amount_crypto": amount_crypto, "reference": reference, "provider": provider, "subclass": subclass, } self.log.info(f"Storing trade information: {str(to_store)}") db.r.hmset(f"trade.{reference}", to_store) db.r.set(f"trade.{trade_id}.reference", reference) self.irc.sendmsg(f"Generated reference for {trade_id}: {reference}") self.ux.notify.notify_new_trade(amount, currency) uid = self.ux.verify.create_uid(subclass, buyer) verified = self.ux.verify.get_external_user_id_status(uid) if verified != "GREEN": self.log.info(f"UID {uid} is not verified, sending link.") self.antifraud.send_verification_url(subclass, uid, trade_id) else: # User is verified self.log.info(f"UID {uid} is verified.") self.markets.send_bank_details(subclass, currency, trade_id) self.markets.send_reference(subclass, trade_id, reference) if existing_ref: return util.convert(existing_ref) else: return reference def find_trade(self, 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 = db.get_refs() matching_refs = [] # TODO: use get_ref_map in this function instead of calling get_ref multiple times for ref in refs: stored_trade = db.get_ref(ref) if stored_trade["currency"] == currency and float( stored_trade["amount"] ) == float(amount): matching_refs.append(stored_trade) if len(matching_refs) != 1: self.log.error( f"Find trade returned multiple results for TXID {txid}: {matching_refs}" ) return False return matching_refs[0]