pluto/handler/lib/transactions.py

394 lines
16 KiB
Python

# Twisted/Klein imports
from twisted.internet.defer import inlineCallbacks
# Other library imports
from json import dumps
from random import choices
from string import ascii_uppercase
# Project imports
from settings import settings
import db
import util
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"]
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
yield self.release_funds(stored_trade["id"], stored_trade["reference"])
@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 :)")
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]