# Twisted/Klein imports from twisted.logger import Logger # Other library imports from json import dumps from random import choices from string import ascii_uppercase # Project imports from settings import settings from db import r from util import convert class Transactions(object): """ Handler class for incoming Revolut transactions. """ def __init__(self): """ Initialise the Transaction object. Set the logger. """ self.log = Logger("transactions") def set_agora(self, agora): self.agora = agora def set_irc(self, irc): self.irc = irc 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 """ event = data["event"] ts = data["timestamp"] inside = data["data"] txid = inside["id"] if "type" not in inside: self.log.error("Type not in inside: {inside}", inside=inside) return txtype = inside["type"] state = inside["state"] if "reference" in inside: reference = inside["reference"] else: reference = "not_given" leg = inside["legs"][0] if "counterparty" in leg: account_type = leg["counterparty"]["account_type"] else: account_type = "not_given" amount = leg["amount"] currency = leg["currency"] description = leg["description"] to_store = { "event": event, "ts": ts, "txid": txid, "txtype": txtype, "state": state, "reference": reference, "account_type": account_type, "amount": amount, "currency": currency, "description": description, } self.log.info("Transaction processed: {formatted}", formatted=dumps(to_store, indent=2)) r.hmset(f"tx.{txid}", to_store) self.irc.sendmsg(f"AUTO Incoming transaction: {amount}{currency} ({reference}) - {state} - {description}") # 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 = self.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("Multiple references valid for TXID {txid}: {reference}", txid=txid, reference=reference) self.irc.sendmsg(f"Multiple references valid for TXID {txid}: {reference}") return stored_trade = False looked_up_without_reference = False # Amount/currency lookup implementation if not stored_trade_reference: self.log.info(f"No reference in DB refs for {reference}", reference=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("Checking against amount and currency for TXID {txid}", txid=txid) self.irc.sendmsg(f"Checking against amount and currency for TXID {txid}") stored_trade = self.find_trade(txid, currency, amount) if not stored_trade: self.log.info( "Failed to get reference by amount and currency: {txid} {currency} {amount}", txid=txid, currency=currency, amount=amount, ) self.irc.sendmsg(f"Failed to get reference by amount and currency: {txid} {currency} {amount}") return if currency == "USD": amount_usd = amount else: rates = self.agora.get_rates_all() amount_usd = amount / rates[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(f"Not checking against amount and currency as amount exceeds MAX") # Close here if the amount exceeds the allowable limit for no reference return # 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 if not stored_trade: stored_trade = self.get_ref(stored_trade_reference.pop()) if not stored_trade: self.log.info("No reference in DB for {reference}", reference=reference) self.irc.sendmsg(f"No reference in DB for {reference}") return amount = float(amount) stored_trade["amount"] = float(stored_trade["amount"]) # Make sure it was sent in the expected currency if not stored_trade["currency"] == currency: self.log.info( "Currency mismatch, Agora: {currency_agora} / Revolut: {currency}", currency_agora=stored_trade["currency"], currency=currency, ) self.irc.sendmsg(f"Currency mismatch, Agora: {stored_trade['currency']} / Revolut: {currency}") return # Make sure the expected amount was sent if not stored_trade["amount"] == amount: if looked_up_without_reference: return # 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.agora.get_acceptable_margins(currency, amount) self.log.info( "Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}", min_amount=min_amount, max_amount=max_amount, ) self.irc.sendmsg(f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}") if not min_amount < stored_trade["amount"] < max_amount: self.log.info( "Amount mismatch - not in margins: {amount} (min: {min_amount} / max: {max_amount}", amount=stored_trade["amount"], min_amount=min_amount, max_amount=max_amount, ) self.irc.sendmsg(f"Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}") return # Make sure the account type was Revolut, as these are completed instantly if not account_type == "revolut": self.log.info("Account type is not Revolut: {account_type}", account_type=account_type) self.irc.sendmsg(f"Account type is not Revolut: {account_type}") return self.log.info("All checks passed, releasing funds for {trade_id} {reference}", trade_id=stored_trade["id"], reference=reference) self.irc.sendmsg(f"All checks passed, releasing funds for {stored_trade['id']} / {reference}") rtrn = self.agora.release_funds(stored_trade["id"]) self.agora.agora.contact_message_post(stored_trade["id"], "Thanks! Releasing now :)") message = rtrn["message"] message_long = rtrn["response"]["data"]["message"] self.irc.sendmsg(f"{message} - {message_long}") def new_trade(self, asset, trade_id, buyer, currency, amount, amount_crypto): """ 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"{asset}-{reference}" existing_ref = r.get(f"trade.{trade_id}.reference") if not existing_ref: r.set(f"trade.{trade_id}.reference", reference) to_store = { "id": trade_id, "asset": asset, "buyer": buyer, "currency": currency, "amount": amount, "amount_crypto": amount_crypto, "reference": reference, } self.log.info("Storing trade information: {info}", info=str(to_store)) r.hmset(f"trade.{reference}", to_store) self.irc.sendmsg(f"Generated reference for {trade_id}: {reference}") if settings.Agora.Send == "1": self.agora.agora.contact_message_post(trade_id, f"Hi! When sending the payment please use reference code: {reference}") if existing_ref: return convert(existing_ref) else: return reference def find_tx(self, reference, amount): """ Find transactions that match the given reference and amount. :param reference: transaction reference in Revolut :param amount: transaction amount :type reference: string :type amount: int :return: transaction details or AMOUNT_INVALID, or False :rtype: dict or string or bool """ all_transactions = r.scan(0, match="tx.*") for tx_iter in all_transactions[1]: tx_obj = r.hgetall(tx_iter) if tx_obj[b"reference"] == str.encode(reference): if tx_obj[b"amount"] == str.encode(amount): return convert(tx_obj) else: return "AMOUNT_INVALID" return False 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: Revolut 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 = self.get_refs() matching_refs = [] for ref in refs: stored_trade = self.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("Find trade returned multiple results for TXID {txid}: {matching_refs}", txid=txid, matching_refs=matching_refs) return False return matching_refs[0] def get_refs(self): """ Get all reference IDs for trades. :return: list of trade IDs :rtype: list """ references = [] ref_keys = r.keys("trade.*.reference") for key in ref_keys: references.append(r.get(key)) return convert(references) def get_ref_map(self): """ Get all reference IDs for trades. :return: dict of references keyed by TXID :rtype: dict """ references = {} ref_keys = r.keys("trade.*.reference") for key in ref_keys: tx = convert(key).split(".")[1] references[tx] = r.get(key) return convert(references) def get_ref(self, reference): """ Get a reference ID for a single trade. :return: trade ID :rtype: string """ ref_data = r.hgetall(f"trade.{reference}") ref_data = convert(ref_data) if not ref_data: return False return ref_data def del_ref(self, reference): """ Delete a given reference from the Redis database. """ tx = self.ref_to_tx(reference) r.delete(f"trade.{reference}") r.delete(f"trade.{tx}.reference") def cleanup(self, references): for tx, reference in self.get_ref_map().items(): if reference not in references: self.log.info("Archiving trade reference: {reference} / TX: {tx}", reference=reference, tx=tx) r.rename(f"trade.{tx}.reference", f"archive.trade.{tx}.reference") r.rename(f"trade.{reference}", f"archive.trade.{reference}") def del_tx(self, txid): pass def tx_to_ref(self, tx): refs = self.get_refs() for reference in refs: ref_data = convert(r.hgetall(f"trade.{reference}")) if not ref_data: continue if ref_data["id"] == tx: return reference def ref_to_tx(self, reference): ref_data = convert(r.hgetall(f"trade.{reference}")) if not ref_data: return False return ref_data["id"]