From 1e7d8f6c8dfec63699ca673caf607678dc841194 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Thu, 9 Mar 2023 23:27:16 +0000 Subject: [PATCH] Begin adding platform support --- app/local_settings.py | 3 +- app/urls.py | 23 +- core/clients/aggregator.py | 309 ++++- core/clients/platform.py | 1271 ++++++++++++++++++++ core/clients/platforms/agora.py | 120 ++ core/clients/platforms/api/agoradesk.py | 1031 ++++++++++++++++ core/forms.py | 57 +- core/lib/antifraud.py | 111 ++ core/lib/db.py | 82 +- core/lib/money.py | 497 ++++++++ core/lib/notify.py | 20 +- core/migrations/0008_platform.py | 42 + core/models.py | 67 ++ core/templates/base.html | 2 +- core/templates/partials/platform-list.html | 81 ++ core/views/platforms.py | 50 + requirements.txt | 5 +- 17 files changed, 3724 insertions(+), 47 deletions(-) create mode 100644 core/clients/platform.py create mode 100644 core/clients/platforms/agora.py create mode 100644 core/clients/platforms/api/agoradesk.py create mode 100644 core/lib/antifraud.py create mode 100644 core/lib/money.py create mode 100644 core/migrations/0008_platform.py create mode 100644 core/templates/partials/platform-list.html create mode 100644 core/views/platforms.py diff --git a/app/local_settings.py b/app/local_settings.py index 37ee073..b087578 100644 --- a/app/local_settings.py +++ b/app/local_settings.py @@ -36,7 +36,8 @@ ELASTICSEARCH_USERNAME = getenv("ELASTICSEARCH_USERNAME", "elastic") ELASTICSEARCH_PASSWORD = getenv("ELASTICSEARCH_PASSWORD", "changeme") ELASTICSEARCH_HOST = getenv("ELASTICSEARCH_HOST", "localhost") ELASTICSEARCH_TLS = getenv("ELASTICSEARCH_TLS", "false") in trues - +ELASTICSEARCH_INDEX = getenv("ELASTICSEARCH_INDEX", "pluto") +ELASTICSEARCH_INDEX_ADS = getenv("ELASTICSEARCH_INDEX_ADS", "ads") DEBUG = getenv("DEBUG", "false").lower() in trues PROFILER = getenv("PROFILER", "false").lower() in trues diff --git a/app/urls.py b/app/urls.py index 19b8c14..011201d 100644 --- a/app/urls.py +++ b/app/urls.py @@ -20,7 +20,7 @@ from django.contrib.auth.views import LogoutView from django.urls import include, path from two_factor.urls import urlpatterns as tf_urls -from core.views import aggregators, banks, base, notifications +from core.views import aggregators, banks, base, notifications, platforms # from core.views.stripe_callbacks import Callback @@ -122,4 +122,25 @@ urlpatterns = [ banks.BanksTransactions.as_view(), name="transactions", ), + # Platforms + path( + "platforms//", + platforms.PlatformList.as_view(), + name="platforms", + ), + path( + "platforms//create/", + platforms.PlatformCreate.as_view(), + name="platform_create", + ), + path( + "platforms//update//", + platforms.PlatformUpdate.as_view(), + name="platform_update", + ), + path( + "platforms//delete//", + platforms.PlatformDelete.as_view(), + name="platform_delete", + ), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/clients/aggregator.py b/core/clients/aggregator.py index ed50518..32abef4 100644 --- a/core/clients/aggregator.py +++ b/core/clients/aggregator.py @@ -1,6 +1,11 @@ from abc import ABC -from core.lib.db import convert, r +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): @@ -52,18 +57,312 @@ class AggregatorClient(ABC): # for transaction_id in transaction_ids: if not transaction_ids: return - r.sadd(new_key_name, *transaction_ids) + db.r.sadd(new_key_name, *transaction_ids) - difference = list(r.sdiff(new_key_name, old_key_name)) + difference = list(db.r.sdiff(new_key_name, old_key_name)) - difference = convert(difference) + 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 - r.rename(new_key_name, old_key_name) + 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() + + 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: {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 = self.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) diff --git a/core/clients/platform.py b/core/clients/platform.py new file mode 100644 index 0000000..06fec2e --- /dev/null +++ b/core/clients/platform.py @@ -0,0 +1,1271 @@ +# Twisted/Klein imports +import asyncio +from abc import ABC +from datetime import datetime, timezone +from random import choices +from string import ascii_uppercase + +from django.conf import settings + +from core.clients.platforms.api.agoradesk import AgoraDesk +from core.lib import db, notify +from core.lib.antifraud import antifraud +from core.lib.money import money +from core.util import logs + +# Other library imports +# from orjson import loads + + +log = logs.get_logger("platform") + + +class LocalPlatformClient(ABC): + """ + Initialise the Local API library for Agora. + """ + + async def connect(self): + self.api = AgoraDesk(self.instance.token) + + # TODO: do in schedules + # def setup_loop(self): + # """ + # Set up the LoopingCall to get all active trades and messages. + # """ + # log.debug("Setting up loops.") + # self.lc_dash = LoopingCall(self.loop_check) + # self.lc_dash.start(int(self.sets.RefreshSec)) + # if settings.Agora.Cheat == "1": + # self.lc_cheat = LoopingCall(self.run_cheat_in_thread) + # self.lc_cheat.start(int(self.sets.CheatSec)) + # log.debug("Finished setting up loops.") + + def map_provider(self, provider, reverse=False): + provider_map = {"NATIONAL_BANK": "national-bank-transfer"} + if reverse: + try: + return next( + key for key, value in provider_map.items() if value == provider + ) + except StopIteration: + return False + else: + try: + return provider_map[provider] + except KeyError: + return False + + async def got_dashboard(self, dash): + dash_tmp = await self.wrap_dashboard(dash) + await self.dashboard_hook(dash_tmp) + + async def wrap_dashboard(self, dash=None): # backwards compatibility with TX + if not dash: + dash = await self.api.dashboard() + # if dash["response"] is None: + # return False + dash_tmp = {} + if not dash: + return False + if not dash["response"]: + return False + if "data" not in dash["response"]: + # log.error(f"Data not in dashboard response: {dash}") + return dash_tmp + if dash["response"]["data"]["contact_count"] > 0: + for contact in dash["response"]["data"]["contact_list"]: + contact_id = contact["data"]["contact_id"] + dash_tmp[contact_id] = contact + return dash_tmp + + async def loop_check(self): + """ + Calls hooks to parse dashboard info and get all contact messages. + """ + dashboard_response = await self.api.dashboard() + await self.got_dashboard(dashboard_response) + + # Get recent messages + messages = await self.api.recent_messages() + await self.got_recent_messages(messages) + + # async def get_dashboard_irc(self): + # """ + # Get dashboard helper for IRC only. + # """ + # dash = await self.wrap_dashboard() + # rtrn = [] + # if dash is False: + # return False + # for contact_id, contact in dash.items(): + # reference = db.tx_to_ref(contact_id) + # buyer = contact["data"]["buyer"]["username"] + # amount = contact["data"]["amount"] + # if self.platform == "agora": + # asset = contact["data"]["advertisement"]["asset"] + # elif self.platform == "lbtc": + # asset = "BTC" + # if asset == "XMR": + # amount_crypto = contact["data"]["amount_xmr"] + # elif asset == "BTC": + # amount_crypto = contact["data"]["amount_btc"] + # currency = contact["data"]["currency"] + # provider = contact["data"]["advertisement"]["payment_method"] + # if not contact["data"]["is_selling"]: + # continue + # rtrn.append( + # ( + # f"[#] [{reference}] ({self.platform}) <{buyer}>" + # f" {amount}{currency} {provider} {amount_crypto}{asset}" + # ) + # ) + # return rtrn + + async def dashboard_hook(self, dash): + """ + Get information about our open trades. + Post new trades to IRC and cache trades for the future. + """ + current_trades = [] + if not dash: + return + if not dash.items(): + return + for contact_id, contact in dash.items(): + reference = await db.tx_to_ref(str(contact_id)) + if reference: + current_trades.append(reference) + buyer = contact["data"]["buyer"]["username"] + amount = contact["data"]["amount"] + if self.platform == "agora": + asset = contact["data"]["advertisement"]["asset"] + elif self.platform == "lbtc": + asset = "BTC" + provider = contact["data"]["advertisement"]["payment_method"] + if asset == "XMR": + amount_crypto = contact["data"]["amount_xmr"] + elif asset == "BTC": + amount_crypto = contact["data"]["amount_btc"] + currency = contact["data"]["currency"] + if not contact["data"]["is_selling"]: + continue + if reference not in self.last_dash: + reference = await self.new_trade( + self.platform, + asset, + contact_id, + buyer, + currency, + amount, + amount_crypto, + provider, + ) + if reference: + if reference not in current_trades: + current_trades.append(reference) + # Let us know there is a new trade + title = "New trade" + message = ( + f"[#] [{reference}] ({self.platform}) <{buyer}>" + f" {amount}{currency} {provider} {amount_crypto}{asset}" + ) + await notify.sendmsg(self.instance.user, message, title=title) + # Note that we have seen this reference + self.last_dash.add(reference) + + # Purge old trades from cache + for ref in list(self.last_dash): # We're removing from the list on the fly + if ref not in current_trades: + self.last_dash.remove(ref) + if reference and reference not in current_trades: + current_trades.append(reference) + messages = await db.cleanup(self.platform, current_trades) + for message in messages: + await notify.sendmsg(self.instance.user, message, title="Cleanup") + + async def got_recent_messages(self, messages, send_irc=True): + """ + Get recent messages. + """ + messages_tmp = {} + if not messages: + return False + if not messages["success"]: + return False + if not messages["response"]: + return False + if "data" not in messages["response"]: + log.error(f"Data not in messages response: {messages['response']}") + return False + open_tx = db.get_ref_map().keys() + for message in messages["response"]["data"]["message_list"]: + contact_id = str(message["contact_id"]) + username = message["sender"]["username"] + msg = message["msg"] + if contact_id not in open_tx: + continue + reference = db.tx_to_ref(contact_id) + if reference in messages_tmp: + messages_tmp[reference].append([username, msg]) + else: + messages_tmp[reference] = [[username, msg]] + + # Send new messages on IRC + if send_irc: + for user, message in messages_tmp[reference][::-1]: + if reference in self.last_messages: + if not [user, message] in self.last_messages[reference]: + self.irc.sendmsg( + f"[{reference}] ({self.platform}) <{user}> {message}" + ) + # Append sent messages to last_messages so we don't send + # them again + self.last_messages[reference].append([user, message]) + else: + self.last_messages[reference] = [[user, message]] + for x in messages_tmp[reference]: + self.irc.sendmsg( + f"[{reference}] ({self.platform}) <{user}> {message}" + ) + + # Purge old trades from cache + for ref in list(self.last_messages): # We're removing from the list on the fly + if ref not in messages_tmp: + del self.last_messages[ref] + + return messages_tmp + + async def enum_ad_ids(self, page=0): + if self.platform == "lbtc" and page == 0: + page = 1 + ads = await self.api.ads(page=page) + # ads = await self.api._api_call(api_method="ads", query_values={"page": page}) + if ads is False: + return False + ads_total = [] + if not ads["success"]: + return False + for ad in ads["response"]["data"]["ad_list"]: + ads_total.append(ad["data"]["ad_id"]) + if "pagination" in ads["response"]: + if "next" in ads["response"]["pagination"]: + page += 1 + ads_iter = await self.enum_ad_ids(page) + if ads_iter is None: + return False + if ads_iter is False: + return False + for ad in ads_iter: + ads_total.append(ad) + return ads_total + + async def enum_ads(self, requested_asset=None, page=0): + if self.platform == "lbtc" and page == 0: + page = 1 + query_values = {"page": page} + if requested_asset: + query_values["asset"] = requested_asset + # ads = await self.api._api_call(api_method="ads", query_values=query_values) + ads = await self.api.ads(page=page) + if ads is False: + return False + ads_total = [] + if not ads["success"]: + return False + if not ads["response"]: + return False + for ad in ads["response"]["data"]["ad_list"]: + if self.platform == "agora": + asset = ad["data"]["asset"] + elif self.platform == "lbtc": + asset = "BTC" + ad_id = ad["data"]["ad_id"] + country = ad["data"]["countrycode"] + currency = ad["data"]["currency"] + provider = ad["data"]["online_provider"] + ads_total.append([asset, ad_id, country, currency, provider]) + if "pagination" in ads["response"]: + if "next" in ads["response"]["pagination"]: + page += 1 + ads_iter = await self.enum_ads(requested_asset, page) + if ads_iter is None: + return False + if ads_iter is False: + return False + for ad in ads_iter: + ads_total.append([ad[0], ad[1], ad[2], ad[3], ad[4]]) + return ads_total + + def last_online_recent(self, date): + """ + Check if the last online date was recent. + :param date: date last online + :type date: string + :return: bool indicating whether the date was recent enough + :rtype: bool + """ + if "+" in date: + # for LBTC + # 2022-04-16T08:53:58+00:00 + date_split = date.split("+") + date_split[1].replace(".", "") + date_split[1].replace(":", "") + date = "+".join(date_split) + date_string = "%Y-%m-%dT%H:%M:%S%z" + now = datetime.now(timezone.utc) + else: + date_string = "%Y-%m-%dT%H:%M:%S.%fZ" + now = datetime.now() + date_parsed = datetime.strptime(date, date_string) + sec_ago_date = (now - date_parsed).total_seconds() + return sec_ago_date < 172800 + + async def enum_public_ads(self, asset, currency, providers=None, page=0): + if self.platform == "lbtc" and page == 0: + page = 1 + to_return = [] + # if asset == "XMR": + # coin = "monero" + # elif asset == "BTC": + # coin = "bitcoins" + if not providers: + providers = ["NATIONAL_BANK"] + # buy-monero-online, buy-bitcoin-online + # Work around Agora weirdness calling it bitcoins + # ads = await self.api._api_call( + # api_method=f"buy-{coin}-online/{currency}", + # query_values={"page": page}, + # ) + if asset == "XMR": + ads = await self.api.buy_monero_online(currency_code=currency, page=page) + elif asset == "BTC": + ads = await self.api.buy_bitcoins_online(currency_code=currency, page=page) + # with open("pub.json", "a") as f: + # import json + # f.write(json.dumps([page, currency, asset, ads])+"\n") + # f.close() + if ads is None: + return False + if ads is False: + return False + if ads["response"] is None: + return False + if "data" not in ads["response"]: + return False + for ad in ads["response"]["data"]["ad_list"]: + provider = ad["data"]["online_provider"] + if self.platform == "lbtc": + provider_test = self.map_provider(provider) + else: + provider_test = provider + if provider_test not in providers: + continue + date_last_seen = ad["data"]["profile"]["last_online"] + # Check if this person was seen recently + if not self.last_online_recent(date_last_seen): + continue + ad_id = str(ad["data"]["ad_id"]) + username = ad["data"]["profile"]["username"] + temp_price = ad["data"]["temp_price"] + if ad["data"]["currency"] != currency: + continue + to_append = [ad_id, username, temp_price, provider, asset, currency] + if to_append not in to_return: + to_return.append(to_append) + # await [ad_id, username, temp_price, provider, asset, currency] + if "pagination" in ads["response"]: + if "next" in ads["response"]["pagination"]: + page += 1 + ads_iter = await self.enum_public_ads(asset, currency, providers, page) + if ads_iter is None: + return False + if ads_iter is False: + return False + for ad in ads_iter: + to_append = [ad[0], ad[1], ad[2], ad[3], ad[4], ad[5]] + if to_append not in to_return: + to_return.append(to_append) + return to_return + + async def run_cheat_in_thread(self, assets=None): + """ + Update prices in another thread. + """ + if not assets: + all_assets = ["XMR"] + assets_not_run = set(all_assets) ^ set(self.cheat_run_on) + if not assets_not_run: + self.cheat_run_on = [] + asset = list(all_assets).pop() + self.cheat_run_on.append(asset) + else: + asset = assets_not_run.pop() + self.cheat_run_on.append(asset) + await self.update_prices([asset]) + return asset + else: + # deferToThread(self.update_prices, assets) + await self.update_prices(assets) + + async def update_prices(self, assets=None): + # Get all public ads for the given assets + public_ads = await self.get_all_public_ads(assets) + if not public_ads: + return False + + # Get the ads to update + to_update = self.get_new_ad_equations(self.platform, public_ads, assets) + await self.slow_ad_update(to_update) + + async def get_all_public_ads(self, assets=None, currencies=None, providers=None): + """ + Get all public ads for our listed currencies. + :return: dict of public ads keyed by currency + :rtype: dict + """ + public_ads = {} + crypto_map = { + "XMR": "monero", + "BTC": "bitcoin", + } + + if not assets: + assets = self.get_all_assets(self.platform) + # Get all currencies we have ads for, deduplicated + if not currencies: + currencies = self.get_all_currencies(self.platform) + if not providers: + providers = self.get_all_providers(self.platform) + sinks_currencies = self.currencies + supported_currencies = [ + currency for currency in currencies if currency in sinks_currencies + ] + currencies = supported_currencies + # We want to get the ads for each of these currencies and return the result + rates = await money.cg.get_price( + ids=["monero", "bitcoin"], vs_currencies=currencies + ) + for asset in assets: + for currency in currencies: + cg_asset_name = crypto_map[asset] + try: + rates[cg_asset_name][currency.lower()] + except KeyError: + log.debug(f"Error getting public ads for currency: {currency}") + continue + ads_list = await self.enum_public_ads(asset, currency, providers) + if not ads_list: + log.debug("Error getting ads list.") + continue + ads = await money.lookup_rates(self.platform, ads_list, rates=rates) + if not ads: + log.debug("Error lookup up rates.") + continue + log.debug("Writing to ES.") + await self.write_to_es_ads("ads", ads) + if currency in public_ads: + for ad in list(ads): + if ad not in public_ads[currency]: + public_ads[currency].append(ad) + else: + public_ads[currency] = ads + + return public_ads + + async def write_to_es_ads(self, msgtype, ads): + for ad in ads: + cast = { + "id": ad[0], + "username": ad[1], + "price": ad[2], + "provider": ad[3], + "asset": ad[4], + "currency": ad[5], + "margin": ad[6], + "ts": str(datetime.now().isoformat()), + "xtype": msgtype, + "market": self.platform, + "type": "platform", + "user_id": self.instance.user.id, + "platform_id": self.instance.id, + } + await money.es.index(index=settings.ELASTICSEARCH_INDEX_ADS, body=cast) + + async def slow_ad_update(self, ads): + """ + Slow ad equation update utilising exponential backoff in order to guarantee all + ads are updated. + :param ads: our list of ads + """ + iterations = 0 + throttled = 0 + assets = set() + currencies = set() + while not all([x[4] for x in ads]) or iterations == 1000: + for ad_index in range(len(ads)): + ad_id, new_formula, asset, currency, actioned = ads[ad_index] + assets.add(asset) + currencies.add(currency) + if not actioned: + rtrn = await self.api.ad_equation(ad_id, new_formula) + if rtrn["success"]: + ads[ad_index][4] = True + throttled = 0 + continue + else: + if "error_code" not in rtrn["response"]["error"]: + log.error( + ( + f"Error code not in return for ad {ad_id}: " + f"{rtrn['response']}" + ) + ) + return + if rtrn["response"]["error"]["error_code"] == 429: + throttled += 1 + sleep_time = pow(throttled, 1.9) + log.info( + ( + f"Throttled {throttled} times while updating " + f"{ad_id}, " + f"sleeping for {sleep_time} seconds" + ) + ) + # We're running in a thread, so this is fine + await asyncio.sleep(sleep_time) + log.error(f"Error updating ad {ad_id}: {rtrn['response']}") + continue + iterations += 1 + + async def nuke_ads(self): + """ + Delete all of our adverts. + :return: True or False + :rtype: bool + """ + ads = await self.enum_ad_ids() + return_ids = [] + if ads is False: + return False + for ad_id in ads: + rtrn = await self.api.ad_delete(ad_id) + return_ids.append(rtrn["success"]) + return all(return_ids) + + async def create_ad( + self, + asset, + countrycode, + currency, + provider, + payment_details, + visible=None, + edit=False, + ad_id=None, + ): + """ + Post an ad with the given asset in a country with a given currency. + Convert the min and max amounts from settings to the given currency with + CurrencyRates. + :param asset: the crypto asset to list (XMR or BTC) + :type asset: string + :param countrycode: country code + :param currency: currency code + :param payment_details: the payment details + :type countrycode: string + :type currency: string + :type payment_details: dict + :return: data about created object or error + :rtype: dict + """ + + if payment_details: + payment_details_text = self.format_payment_details( + currency, payment_details + ) + ad_text = self.format_ad(asset, currency, payment_details_text) + min_amount, max_amount = money.get_minmax(self.platform, asset, currency) + if self.platform == "lbtc": + bank_name = payment_details["bank"] + + # if self.platform == "agora": + price_formula = ( + f"coingecko{asset.lower()}usd*" + f"usd{currency.lower()}*{self.instance.margin}" + ) + # elif self.platform == "lbtc": + # price_formula = f"btc_in_usd*{self.instance.margin}*USD_in_{currency}" + + form = { + "country_code": countrycode, + "currency": currency, + "trade_type": "ONLINE_SELL", + # "asset": asset, + "price_equation": price_formula, + "track_max_amount": False, + "require_trusted_by_advertiser": False, + "online_provider": provider, + "require_feedback_score": 0, + } + if self.platform == "agora": + form["asset"] = asset + form["payment_method_details"] = "Revolut" + form["online_provider"] = provider + elif self.platform == "lbtc": + form["online_provider"] = self.map_provider(provider, reverse=True) + + if visible is False: + form["visible"] = False + elif visible is True: + form["visible"] = False + if payment_details: + form["account_info"] = payment_details_text + form["msg"] = ad_text + form["min_amount"] = round(min_amount, 2) + form["max_amount"] = round(max_amount, 2) + if self.platform == "lbtc": + form["bank_name"] = bank_name + if edit: + ad = await self.api.ad(ad_id=ad_id, **form) + else: + ad = await self.api.ad_create(**form) + return ad + + async def dist_countries(self, filter_asset=None): + """ + Distribute our advert into all countries and providers listed in the config. + Exits on errors. + :return: False or dict with response + :rtype: bool or dict + """ + dist_list = list(self.create_distribution_list(self.platform, filter_asset)) + our_ads = await self.enum_ads() + ( + supported_currencies, + account_info, + ) = self.get_valid_account_details(self.platform) + # Let's get rid of the ad IDs and make it a tuple like dist_list + our_ads = [(x[0], x[2], x[3], x[4]) for x in our_ads] + if not our_ads: + log.error("Could not get our ads.") + return False + to_return = [] + for asset, countrycode, currency, provider in dist_list: + if (asset, countrycode, currency, provider) not in our_ads: + if currency in supported_currencies: + # Create the actual ad and pass in all the stuff + rtrn = await self.create_ad( + asset, + countrycode, + currency, + provider, + payment_details=account_info[currency], + ) + # Bail on first error, let's not continue + if rtrn is False: + return False + to_return.append(rtrn) + return to_return + + async def redist_countries(self): + """ + Redistribute our advert details into all our listed adverts. + This will edit all ads and update the details. Only works if we have already + run dist. + This will not post any new ads. + Exits on errors. + :return: False or dict with response + :rtype: bool or dict + """ + our_ads = await self.enum_ads() + ( + supported_currencies, + account_info, + ) = self.get_valid_account_details(self.platform) + if not our_ads: + log.error("Could not get our ads.") + return False + to_return = [] + for asset, ad_id, countrycode, currency, provider in our_ads: + if currency in supported_currencies: + rtrn = await self.create_ad( + asset, + countrycode, + currency, + provider, + payment_details=account_info[currency], + edit=True, + ad_id=ad_id, + ) + # Bail on first error, let's not continue + if rtrn is False: + return False + to_return.append((rtrn, ad_id)) + return to_return + + async def strip_duplicate_ads(self): + """ + Remove duplicate ads. + :return: list of duplicate ads + :rtype: list + """ + existing_ads = await self.enum_ads() + _size = len(existing_ads) + repeated = [] + for i in range(_size): + k = i + 1 + for j in range(k, _size): + if ( + existing_ads[i] == existing_ads[j] + and existing_ads[i] not in repeated + ): + repeated.append(existing_ads[i]) + + actioned = [] + for ad_id, country, currency in repeated: + rtrn = await self.api.ad_delete(ad_id) + actioned.append(rtrn["success"]) + + return all(actioned) + + async def release_funds(self, trade_id, reference): + stored_trade = await db.get_ref(reference) + platform = stored_trade["subclass"] + logmessage = f"All checks passed, releasing funds for {trade_id} {reference}" + log.info(logmessage) + title = "Releasing escrow" + await notify.sendmsg(self.instance.user, logmessage, title=title) + release = self.release_funds + post_message = self.api.contact_message_post + + rtrn = await release(trade_id) + if rtrn["message"] == "OK": + post_message(trade_id, "Thanks! Releasing now :)") + return True + else: + logmessage = f"Release funds unsuccessful: {rtrn['message']}" + title = "Release unsuccessful" + log.error(logmessage) + await notify.sendmsg(self.instance.user, logmessage, title=title) + return + + # Parse the escrow release response + message = rtrn["message"] + # message_long = rtrn["response"]["data"]["message"] + self.irc.sendmsg(f"{dumps(message)}") + + async def update_trade_tx(self, reference, txid): + """ + Update a trade to point to a given transaction ID. + Return False if the trade already has a mapped transaction. + """ + existing_tx = await db.r.hget(f"trade.{reference}", "tx") + if existing_tx is None: + return None + elif existing_tx == b"": + await db.r.hset(f"trade.{reference}", "tx", txid) + return True + else: # Already a mapped transaction + return False + + async 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 = await db.get_ref(reference) + if not stored_trade: + log.error(f"Could not get stored trade for {reference}.") + return None + tx_obj = await db.get_tx(tx) + if not tx_obj: + 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 = await self.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) + antifraud.add_bank_sender(platform, platform_buyer, bank_sender) + return True + elif is_updated is False: + # Already mapped + log.error(f"Trade {reference} already has a TX mapped, cannot map {tx}.") + return False + + async 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"AGR-{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, + } + log.info(f"Storing trade information: {str(to_store)}") + await db.r.hmset(f"trade.{reference}", to_store) + await db.r.set(f"trade.{trade_id}.reference", reference) + message = f"Generated reference for {trade_id}: {reference}" + title = "Generated reference" + await notify.sendmsg(self.instance.user, message, title=title) + # uid = self.ux.verify.create_uid(subclass, buyer) + # verified = self.ux.verify.get_external_user_id_status(uid) + # if verified != "GREEN": + # log.info(f"UID {uid} is not verified, sending link.") + # self.antifraud.send_verification_url(subclass, uid, trade_id) + # else: # User is verified + # log.info(f"UID {uid} is verified.") + self.send_bank_details(subclass, currency, trade_id) + self.send_reference(subclass, trade_id, reference) + if existing_ref: + return db.convert(existing_ref) + else: + return reference + + def get_uid(self, external_user_id): + """ + Get the platform and username from the external user ID. + """ + spl = external_user_id.split("|") + if not len(spl) == 2: + log.error(f"Split invalid, cannot get customer: {spl}") + return False + platform, username = spl + return (platform, username) + + async def find_trades_by_uid(self, uid): + """ + Find a list of trade IDs and references by a customer UID. + :return: tuple of (platform, trade_id, reference, currency) + """ + platform, username = self.get_uid(uid) + refs = await db.get_refs() + matching_trades = [] + for reference in refs: + ref_data = await db.get_ref(reference) + tx_platform = ref_data["subclass"] + tx_username = ref_data["buyer"] + trade_id = ref_data["id"] + currency = ref_data["currency"] + if tx_platform == platform and tx_username == username: + to_append = (platform, trade_id, reference, currency) + matching_trades.append(to_append) + return matching_trades + + def get_send_settings(self, platform): + if platform == "agora": + send_setting = self.instance.send + post_message = self.api.contact_message_post + + return (send_setting, post_message) + + async def send_reference(self, platform, trade_id, reference): + """ + Send the reference to a customer. + """ + send_setting, post_message = self.get_send_settings(platform) + if send_setting is True: + await post_message( + trade_id, + f"When sending the payment please use reference code: {reference}", + ) + + async def send_bank_details(self, platform, currency, trade_id): + """ + Send the bank details to a trade. + """ + send_setting, post_message = self.get_send_settings(platform) + log.info(f"Sending bank details/reference for {platform}/{trade_id}") + if send_setting == "1": + account_info = self.get_matching_account_details(platform, currency) + formatted_account_info = self.format_payment_details( + currency, account_info, real=True + ) + if not formatted_account_info: + log.error(f"Payment info invalid: {formatted_account_info}") + return + post_message( + trade_id, + f"Payment details: \n{formatted_account_info}", + ) + + def get_all_assets(self, platform): + return ["XMR"] # TODO + + def get_all_providers(self, platform): + return ["REVOLUT"] # TODO + + def get_all_currencies(self, platform): + raise Exception + + def get_new_ad_equations(self, platform, public_ads, assets=None): + """ + Update all our prices. + :param public_ads: dictionary of public ads keyed by currency + :type public_ads: dict + :return: list of ads to modify + :rtype: list + """ + username = self.instance.username + min_margin = self.instance.min_margin + max_margin = self.instance.max_margin + + to_update = [] + + # NOTES: + # Get all ads for each currency, with all the payment methods. + # Create a function to, in turn, filter these so it contains only one payment + # method. Run autoprice on this. + # Append all results to to_update. Repeat for remaining payment methods, then + # call slow update. + + # (asset, currency, provider) + if not assets: + assets = self.get_all_assets(platform) + currencies = self.currencies + providers = self.get_all_providers(platform) + # if platform == "lbtc": + # providers = [ + # self.sources.lbtc.map_provider(x, reverse=True) for x in providers + # ] + + brute = [ + (asset, currency, provider) + for asset in assets + for currency in currencies + for provider in providers + ] + for asset, currency, provider in brute: + # Filter currency + try: + public_ads_currency = public_ads[currency] + except KeyError: + if currency == "GBP": + log.error("Error getting public ads for currency GBP, aborting") + break + continue + # Filter asset + public_ads_filtered = [ad for ad in public_ads_currency if ad[4] == asset] + + # Filter provider + public_ads_filtered = [ + ad for ad in public_ads_filtered if ad[3] == provider + ] + our_ads = [ad for ad in public_ads_filtered if ad[1] == username] + if not our_ads: + log.warning( + ( + f"No ads found in {platform} public listing for " + f"{asset} {currency}" + f" {provider}" + ) + ) + continue + new_margin = self.autoprice( + username, min_margin, max_margin, public_ads_filtered, currency + ) + if platform == "agora": + new_formula = ( + f"coingecko{asset.lower()}usd*usd{currency.lower()}*{new_margin}" + ) + elif platform == "lbtc": + new_formula = f"btc_in_usd*{new_margin}*USD_in_{currency}" + for ad in our_ads: + ad_id = ad[0] + asset = ad[4] + our_margin = ad[5] + if new_margin != our_margin: + to_update.append([str(ad_id), new_formula, asset, currency, False]) + + return to_update + + def autoprice(self, username, min_margin, max_margin, ads, currency): + """ + Helper function to automatically adjust the price up/down in certain markets + in order to gain the most profits and sales. + :param ads: list of ads + :type ads: list of lists + :param currency: currency of the ads + :type currency: string + :return: the rate we should use for this currency + :rtype: float + """ + # log.debug("Autoprice starting for {x}", x=currency) + # Find cheapest ad + # Filter by 3rd index on each ad list to find the cheapest + min_margin_ad = min(ads, key=lambda x: x[6]) + # log.debug("Minimum margin ad: {x}", x=min_margin_ad) + + # Find second cheapest that is not us + # Remove results from ads that are us + ads_without_us = [ad for ad in ads if not ad[1] == username] + # log.debug("Ads without us: {x}", x=ads_without_us) + # Find ads above our min that are not us + ads_above_our_min_not_us = [ + ad for ad in ads_without_us if ad[6] > float(min_margin) + ] + # log.debug("Ads above our min not us: {x}", x=ads_above_our_min_not_us) + # Check that this list without us is not empty + if ads_without_us: + # Find the cheapest from these + min_margin_ad_not_us = min(ads_without_us, key=lambda x: x[6]) + # log.debug("Min margin ad not us: {x}", x=min_margin_ad_not_us) + # Lowball the lowest ad that is not ours + lowball_lowest_not_ours = min_margin_ad_not_us[6] # - 0.005 + # log.debug("Lowball lowest not ours: {x}", x=lowball_lowest_not_ours) + + # Check if the username field of the cheapest ad matches ours + if min_margin_ad[1] == username: + # log.debug("We are the cheapest for: {x}", x=currency) + # We are the cheapest! + # Are all of the ads ours? + all_ads_ours = all([ad[1] == username for ad in ads]) + if all_ads_ours: + # log.debug("All ads are ours for: {x}", x=currency) + # Now we know it's safe to return the maximum value + return float(max_margin) + else: + # log.debug("All ads are NOT ours for: {x}", x=currency) + # All the ads are not ours, but we are first... + # Check if the lowballed, lowest (that is not ours) ad's margin + # is less than our minimum + if lowball_lowest_not_ours < float(min_margin): + # log.debug("Lowball lowest not ours less than MinMargin") + return float(min_margin) + elif lowball_lowest_not_ours > float(max_margin): + # log.debug("Lowball lowest not ours more than MaxMargin") + return float(max_margin) + else: + # log.debug("Returning lowballed figure: + # {x}", x=lowball_lowest_not_ours) + return lowball_lowest_not_ours + else: + # log.debug("We are NOT the cheapest for: {x}", x=currency) + # We are not the cheapest :( + # Check if this list is empty + if not ads_above_our_min_not_us: + # Return the maximum margin? + return float(max_margin) + # Find cheapest ad above our min that is not us + cheapest_ad = min(ads_above_our_min_not_us, key=lambda x: x[4]) + cheapest_ad_margin = cheapest_ad[6] # - 0.005 + if cheapest_ad_margin > float(max_margin): + # log.debug("Cheapest ad not ours more than MaxMargin") + return float(max_margin) + # log.debug("Cheapest ad above our min that is not us: {x}", x=cheapest_ad) + return cheapest_ad_margin + + def create_distribution_list(self, platform, filter_asset=None): + """ + Create a list for distribution of ads. + :return: generator of asset, countrycode, currency, provider + :rtype: generator of tuples + """ + + distlist = [ + ["AUD", "AU"], + ["BGN", "BG"], + ["CAD", "CA"], + ["CHF", "CH"], + ["CZK", "CZ"], + ["DKK", "DK"], + ["GBP", "GB"], + ["USD", "GB"], + ["EUR", "GB"], + ["USD", "US"], + ["GBP", "US"], + ["EUR", "US"], + ["HKD", "HK"], + ["HRK", "HR"], + ["HUF", "HU"], + ["ISK", "IS"], + ["JPY", "JP"], + ["MXN", "MX"], + ["NOK", "NO"], + ["NZD", "NZ"], + ["PLN", "PL"], + ["RON", "RO"], + ["RUB", "RU"], + ["SEK", "SE"], + ["EUR", "SE"], + ["SGD", "SG"], + ["THB", "TH"], + ["TRY", "TR"], + ["ZAR", "ZA"], + ] + # Iterate providers like REVOLUT, NATIONAL_BANK + for provider in self.get_all_providers(platform): + # Iterate assets like XMR, BTC + for asset in self.get_all_assets(platform): + # Iterate pairs of currency and country like EUR, GB + for currency, countrycode in distlist: + if filter_asset: + if asset == filter_asset: + yield (asset, countrycode, currency, provider) + else: + yield (asset, countrycode, currency, provider) + + def get_valid_account_details(self, platform): + currencies = self.instance.currencies + account_info = self.sinks.account_info + currency_account_info_map = {} + for currency in currencies: + for bank, accounts in account_info.items(): + for account in accounts: + if account["currency"] == currency: + currency_account_info_map[currency] = account["account_number"] + currency_account_info_map[currency]["bank"] = bank.split("_")[0] + currency_account_info_map[currency]["recipient"] = account[ + "recipient" + ] + return (currencies, currency_account_info_map) + + def get_matching_account_details(self, platform, currency): + ( + supported_currencies, + currency_account_info_map, + ) = self.get_valid_account_details(platform) + if currency not in supported_currencies: + return False + return currency_account_info_map[currency] + + def _distribute_account_details(self, platform, currencies=None, account_info=None): + """ + Distribute account details for ads. + We will disable ads we can't support. + """ + + if not currencies: + currencies = self.instance.currencies + if not account_info: + account_info = self.instance.account_info + ( + supported_currencies, + currency_account_info_map, + ) = self.get_valid_account_details(platform) + + # not_supported = [currency for currency in all_currencies if + # currency not in supported_currencies] + + our_ads = self.enum_ads() + + supported_ads = [ad for ad in our_ads if ad[3] in supported_currencies] + + not_supported_ads = [ad for ad in our_ads if ad[3] not in supported_currencies] + + for ad in supported_ads: + asset = ad[0] + countrycode = ad[2] + currency = ad[3] + provider = ad[4] + payment_details = currency_account_info_map[currency] + ad_id = ad[1] + self.create_ad( + asset, + countrycode, + currency, + provider, + payment_details, + visible=True, + edit=True, + ad_id=ad_id, + ) + + for ad in not_supported_ads: + asset = ad[0] + countrycode = ad[2] + currency = ad[3] + provider = ad[4] + ad_id = ad[1] + self.create_ad( + asset, + countrycode, + currency, + provider, + payment_details=False, + visible=False, + edit=True, + ad_id=ad_id, + ) + + def distribute_account_details(self, currencies=None, account_info=None): + """ + Helper to distribute the account details for all platforms. + """ + platforms = "agora" + for platform in platforms: + self._distribute_account_details( + platform, currencies=currencies, account_info=account_info + ) + + def format_ad(self, asset, currency, payment_details_text): + """ + Format the ad. + """ + ad = settings.Platform.Ad # TODO + + # Substitute the currency + ad = ad.replace("$CURRENCY$", currency) + + # Substitute the asset + ad = ad.replace("$ASSET$", asset) + + # Substitute the payment details + ad = ad.replace("$PAYMENT$", payment_details_text) + + # Strip extra tabs + ad = ad.replace("\\t", "\t") + return ad + + def format_payment_details(self, currency, payment_details, real=False): + """ + Format the payment details. + """ + if not payment_details: + return False + if real: + payment = settings.Platform.PaymentDetailsReal + else: + payment = settings.Platform.PaymentDetails + + payment_text = "" + for field, value in payment_details.items(): + formatted_name = field.replace("_", " ") + formatted_name = formatted_name.capitalize() + payment_text += f"* {formatted_name}: **{value}**" + if field != list(payment_details.keys())[-1]: # No trailing newline + payment_text += "\n" + + payment = payment.replace("$PAYMENT$", payment_text) + payment = payment.replace("$CURRENCY$", currency) + + return payment diff --git a/core/clients/platforms/agora.py b/core/clients/platforms/agora.py new file mode 100644 index 0000000..f2ca968 --- /dev/null +++ b/core/clients/platforms/agora.py @@ -0,0 +1,120 @@ +# Other library imports +from pyotp import TOTP + +from core.clients.base import BaseClient +from core.clients.platform import LocalPlatformClient +from core.lib.money import Money + + +class AgoraClient(BaseClient, LocalPlatformClient): + """ + AgoraDesk API handler. + """ + + async def release_funds(self, contact_id): + """ + Release funds for a contact_id. + :param contact_id: trade/contact ID + :type contact_id: string + :return: response dict + :rtype: dict + """ + print("CALLING RELEASE FUNDS", contact_id) + if self.instance.dummy: + self.log.error( + f"Running in dummy mode, not releasing funds for {contact_id}" + ) + return + payload = {"tradeId": contact_id, "password": self.sets.Pass} + rtrn = await self.api._api_call( + api_method=f"contact_release/{contact_id}", + http_method="POST", + query_values=payload, + ) + + # Check if we can withdraw funds + await self.withdraw_funds() + + return rtrn + + # TODO: write test before re-enabling adding total_trades + async def withdraw_funds(self): + """ + Withdraw excess funds to our XMR wallets. + """ + print("CALLING WITHDRAW FUNDS") + totals_all = await self.money.get_total() + if totals_all is False: + return False + + wallet_xmr, _ = totals_all[2] + + # Get the wallet balances in USD + total_usd = totals_all[0][1] + + # total_trades_usd = self.tx.get_open_trades_usd() + if not total_usd: + return False + # total_usd += total_trades_usd + + profit_usd = total_usd - float(settings.Money.BaseUSD) + # Get the XMR -> USD exchange rate + xmr_usd = self.money.cg.get_price(ids="monero", vs_currencies=["USD"]) + + # Convert the USD total to XMR + profit_usd_in_xmr = float(profit_usd) / xmr_usd["monero"]["usd"] + + # Check profit is above zero + if not profit_usd >= 0: + return + + if not float(wallet_xmr) > profit_usd_in_xmr: + # Not enough funds to withdraw + self.log.error( + ( + f"Not enough funds to withdraw {profit_usd_in_xmr}, " + f"as wallet only contains {wallet_xmr}" + ) + ) + self.irc.sendmsg( + ( + f"Not enough funds to withdraw {profit_usd_in_xmr}, " + f"as wallet only contains {wallet_xmr}" + ) + ) + self.ux.notify.notify_need_topup(profit_usd_in_xmr) + return + + if not profit_usd >= float(settings.Money.WithdrawLimit): + # Not enough profit to withdraw + return + + half = profit_usd_in_xmr / 2 + + half_rounded = round(half, 8) + + # Read OTP secret + with open("otp.key", "r") as f: + otp_key = f.read() + f.close() + otp_key = otp_key.replace("\n", "") + + # Get OTP code + otp_code = TOTP(otp_key) + + # Set up the format for calling wallet_send_xmr + send_cast = { + "address": None, + "amount": half_rounded, + "password": settings.Agora.Pass, + "otp": otp_code.now(), + } + + send_cast["address"] = settings.XMR.Wallet1 + rtrn1 = await self.api.wallet_send_xmr(**send_cast) + + send_cast["address"] = settings.XMR.Wallet2 + rtrn2 = await self.api.wallet_send_xmr(**send_cast) + + self.irc.sendmsg(f"Withdrawal: {rtrn1['success']} | {rtrn2['success']}") + self.ux.notify.notify_withdrawal(half_rounded) diff --git a/core/clients/platforms/api/agoradesk.py b/core/clients/platforms/api/agoradesk.py new file mode 100644 index 0000000..f5ac78f --- /dev/null +++ b/core/clients/platforms/api/agoradesk.py @@ -0,0 +1,1031 @@ +"""See https://agoradesk.com/api-docs/v1.""" +# pylint: disable=too-many-lines +# Large API. Lots of lines can't be avoided. +import json +import logging +from typing import Any, Dict, List, Optional, Union + +import aiohttp +import arrow + +from core.util import logs + +__author__ = "marvin8" +__copyright__ = "(C) 2021 https://codeberg.org/MarvinsCryptoTools/agoradesk_py" +__version__ = "0.1.0" + +# set logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logging.getLogger("requests.packages.urllib3").setLevel(logging.INFO) +logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) +logger = logs.get_logger(__name__) + +URI_API = "https://agoradesk.com/api/v1/" + + +class AgoraDesk: + """AgoraDesk / LocalMonero API object. + + Documentation: https://agoradesk.com/api-docs/v1 + """ + + # pylint: disable=too-many-public-methods + # API provides this many methods, I can't change that + + def __init__(self, api_key: Optional[str], debug: Optional[bool] = False) -> None: + self.api_key = "" + if api_key: + self.api_key = api_key + self.debug = debug + + if self.debug: + logging.getLogger("requests.packages.urllib3").setLevel(logging.DEBUG) + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + logger.debug("creating instance of AgoraDesk API with api_key %s", self.api_key) + + async def _api_call( + self, + api_method: str, + http_method: Optional[str] = "GET", + query_values: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + api_call_url = URI_API + api_method + + headers = { + "Content-Type": "application/json", + "User-Agent": f"agoradesk_py/{__version__} " + f"https://codeberg.org/MarvinsCryptoTools/agoradesk_py", + "Authorization": self.api_key, + } + cast = { + "headers": headers, + } + + logger.debug("API Call URL: %s", api_call_url) + logger.debug("Headers : %s", headers) + logger.debug("HTTP Method : %s", http_method) + logger.debug("Query Values: %s", query_values) + logger.debug("Query Values as Json:\n%s", json.dumps(query_values)) + + result: Dict[str, Any] = { + "success": False, + "message": "Invalid Method", + "response": None, + "status": None, + } + + response = None + if http_method == "POST": + if query_values: + cast["data"] = query_values + async with aiohttp.ClientSession() as session: + async with session.post(api_call_url, **cast) as response_raw: + response = await response_raw.json() + + else: + # response = httpx.get(url=api_call_url, headers=headers, params=query_values) + # response = treq.get(api_call_url, headers=headers, params=query_values) + cast["params"] = query_values + async with aiohttp.ClientSession() as session: + async with session.get(api_call_url, **cast) as response_raw: + response = await response_raw.json() + if response: + logger.debug(response) + result["status"] = response.code + if response.code == 200: + result["success"] = True + result["message"] = "OK" + else: + result["message"] = "API ERROR" + + return result + return response + + # Account related API Methods + # =========================== + + async def account_info(self, username: str) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserByUsername + """ + return await self._api_call(api_method=f"account_info/{username}") + + # async def dashboard(self) -> Dict[str, Any]: + # """See Agoradesk API. + + # https://agoradesk.com/api-docs/v1#operation/getUserDashboard + # """ + # return await self._api_call(api_method="dashboard") + + async def dashboard_buyer(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserDashboardBuyer + """ + return await self._api_call(api_method="dashboard/buyer") + + async def dashboard(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserDashboardSeller + """ + return await self._api_call(api_method="dashboard/seller") + + async def dashboard_canceled(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserDashboardCanceled + """ + return await self._api_call(api_method="dashboard/canceled") + + async def dashboard_closed(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserDashboardClosed + """ + return await self._api_call(api_method="dashboard/closed") + + async def dashboard_released(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserDashboardReleased + """ + + return await self._api_call(api_method="dashboard/released") + + async def logout(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/logout + """ + + return await self._api_call(api_method="logout", http_method="POST") + + async def myself(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getTokenOwnerUserData + """ + + return await self._api_call(api_method="myself") + + async def notifications(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getUserNotifications + """ + + return await self._api_call(api_method="notifications") + + async def notifications_mark_as_read(self, notification_id: str) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/markNotificationRead + """ + + return await self._api_call( + api_method=f"notifications/mark_as_read/{notification_id}", + http_method="POST", + ) + + async def recent_messages(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getRecemtMessages + """ + + return await self._api_call(api_method="recent_messages") + + # Trade related API Methods + # =========================== + + # post/feedback/{username} • Give feedback to a user + async def feedback( + self, username: str, feedback: str, msg: Optional[str] + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/setUserFeedback + """ + + params = {"feedback": feedback} + if msg: + params["msg"] = msg + return await self._api_call( + api_method=f"feedback/{username}", + http_method="POST", + query_values=params, + ) + + # Todo: + # post/trade/contact_release/{trade_id} • Release trade escrow + # post/contact_fund/{trade_id} • Fund a trade + # post/contact_dispute/{trade_id} • Start a trade dispute + + # post/contact_mark_as_paid/{trade_id} • Mark a trade as paid + async def contact_mark_as_paid(self, trade_id: str) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/markPaid + """ + return await self._api_call( + api_method=f"contact_mark_as_paid/{trade_id}", http_method="POST" + ) + + # post/contact_cancel/{trade_id} • Cancel the trade + async def contact_cancel( + self, + trade_id: str, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/cancelTrade + """ + return await self._api_call( + api_method=f"contact_cancel/{trade_id}", + http_method="POST", + ) + + # Todo: + # post/contact_escrow/{trade_id} • Enable escrow + + # get/contact_messages/{trade_id} • Get trade messages + async def contact_messages( + self, trade_id: str, after: Optional[arrow.Arrow] = None + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getTradeMessages + """ + if after: + reply = self._api_call( + api_method=f"contact_messages/{trade_id}", + query_values={"after": after.to("UTC").isoformat()}, + ) + else: + reply = self._api_call(api_method=f"contact_messages/{trade_id}") + + return reply + + # post/contact_create/{ad_id} • Start a trade + async def contact_create( + self, + ad_id: str, + amount: float, + msg: Optional[str] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/startTrade + """ + payload: Dict[str, Any] = {"amount": amount} + if msg: + payload["msg"] = msg + return await self._api_call( + api_method=f"contact_create/{ad_id}", + http_method="POST", + query_values=payload, + ) + + # get/contact_info/{trade_id} • Get a trade by trade ID + async def contact_info(self, trade_ids: Union[str, List[str]]) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getTradeById and + https://agoradesk.com/api-docs/v1#operation/getTradesInBulk + """ + api_method = "contact_info" + if isinstance(trade_ids, list): + params = "?contacts=" + for trade_id in trade_ids: + params += f"{trade_id}," + params = params[0:-1] + else: + params = f"/{trade_ids}" + api_method += params + return await self._api_call(api_method=api_method) + + # Todo: Add image upload functionality + # post/contact_message_post/{trade_id} • Send a chat message/attachment + async def contact_message_post( + self, trade_id: str, msg: Optional[str] = None + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/sendChatMessage + """ + payload = {"msg": msg} + return await self._api_call( + api_method=f"contact_message_post/{trade_id}", + http_method="POST", + query_values=payload, + ) + + # Todo: + # get/contact_message_attachment/{trade_id}/{attachment_id} + + # Advertisement related API Methods + # ================================ + + async def ad_create( + self, + country_code: str, + currency: str, + trade_type: str, + asset: str, + price_equation: str, + track_max_amount: bool, + require_trusted_by_advertiser: bool, + verified_email_required: Optional[bool] = None, + online_provider: Optional[str] = None, + msg: Optional[str] = None, + min_amount: Optional[float] = None, + max_amount: Optional[float] = None, + limit_to_fiat_amounts: Optional[str] = None, + payment_method_details: Optional[str] = None, + first_time_limit_asset: Optional[float] = None, + require_feedback_score: Optional[int] = None, + account_info: Optional[str] = None, + payment_window_minutes: Optional[int] = None, + floating: Optional[bool] = None, + lat: Optional[float] = None, + lon: Optional[float] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/createAd + """ + + # pylint: disable=too-many-arguments + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # API takes this many arguments, I can't change that + # Too many locals and too many branches goes hand in hand + # with too many arguments + params: Dict[str, Any] = { + "countrycode": country_code, + "currency": currency, + "trade_type": trade_type, + "asset": asset, + "price_equation": price_equation, + "track_max_amount": 1 if track_max_amount else 0, + "require_trusted_by_advertiser": 1 if require_trusted_by_advertiser else 0, + } + if verified_email_required: + params["verified_email_required"] = 1 if verified_email_required else 0 + if online_provider: + params["online_provider"] = online_provider + if msg: + params["msg"] = msg + if min_amount: + params["min_amount"] = min_amount + if max_amount: + params["max_amount"] = max_amount + if limit_to_fiat_amounts: + params["limit_to_fiat_amounts"] = limit_to_fiat_amounts + if payment_method_details: + params["payment_method_detail"] = payment_method_details + if first_time_limit_asset: + params["first_time_limit_asset"] = first_time_limit_asset + if require_feedback_score: + params["require_feedback_score"] = require_feedback_score + if account_info: + params["account_info"] = account_info + if payment_window_minutes: + params["payment_window_minutes"] = payment_window_minutes + if floating: + params["floating"] = 1 if floating else 0 + if lat: + params["lat"] = lat + if lon: + params["lon"] = lon + + return await self._api_call( + api_method="ad-create", + http_method="POST", + query_values=params, + ) + + async def ad( + self, + ad_id: str, + country_code: Optional[str] = None, + currency: Optional[str] = None, + trade_type: Optional[str] = None, + asset: Optional[str] = None, + price_equation: Optional[str] = None, + track_max_amount: Optional[bool] = None, + require_trusted_by_advertiser: Optional[bool] = None, + verified_email_required: Optional[bool] = None, + online_provider: Optional[str] = None, + msg: Optional[str] = None, + min_amount: Optional[float] = None, + max_amount: Optional[float] = None, + limit_to_fiat_amounts: Optional[str] = None, + payment_method_details: Optional[str] = None, + first_time_limit_asset: Optional[float] = None, + require_feedback_score: Optional[int] = None, + account_info: Optional[str] = None, + payment_window_minutes: Optional[int] = None, + floating: Optional[bool] = None, + lat: Optional[float] = None, + lon: Optional[float] = None, + visible: Optional[bool] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/editAd + """ + + # pylint: disable=invalid-name + # Don't want to change the name of the method from what the API call is + # pylint: disable=too-many-arguments + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # API takes this many arguments, I can't change that + # Too many locals and too many branches goes hand in hand + # with too many arguments + params: Dict[str, Union[str, float, bool]] = {} + if country_code: + params["countrycode"] = country_code + if currency: + params["currency"] = currency + if trade_type: + params["trade_type"] = trade_type + if asset: + params["asset"] = asset + if price_equation: + params["price_equation"] = price_equation + if track_max_amount: + params["track_max_amount"] = 1 if track_max_amount else 0 + if require_trusted_by_advertiser: + params["require_trusted_by_advertiser"] = ( + 1 if require_trusted_by_advertiser else 0 + ) + if verified_email_required: + params["verified_email_required"] = 1 if verified_email_required else 0 + if online_provider: + params["online_provider"] = online_provider + if msg: + params["msg"] = msg + if min_amount: + params["min_amount"] = min_amount + if max_amount: + params["max_amount"] = max_amount + if limit_to_fiat_amounts: + params["limit_to_fiat_amounts"] = limit_to_fiat_amounts + if payment_method_details: + params["payment_method_detail"] = payment_method_details + if first_time_limit_asset: + params["first_time_limit_asset"] = first_time_limit_asset + if require_feedback_score: + params["require_feedback_score"] = require_feedback_score + if account_info: + params["account_info"] = account_info + if payment_window_minutes: + params["payment_window_minutes"] = payment_window_minutes + if floating: + params["floating"] = 1 if floating else 0 + if lat: + params["lat"] = lat + if lon: + params["lon"] = lon + if visible: + params["visible"] = True if visible else False + + return await self._api_call( + api_method=f"ad/{ad_id}", + http_method="POST", + query_values=params, + ) + + async def ad_equation(self, ad_id: str, price_equation: str) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/updateFormula + """ + return await self._api_call( + api_method=f"ad-equation/{ad_id}", + http_method="POST", + query_values={"price_equation": price_equation}, + ) + + async def ad_delete(self, ad_id: str) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/deleteAd + """ + return await self._api_call(api_method=f"ad-delete/{ad_id}", http_method="POST") + + async def ads( + self, + country_code: Optional[str] = None, + currency: Optional[str] = None, + trade_type: Optional[str] = None, + visible: Optional[bool] = None, + asset: Optional[str] = None, + payment_method_code: Optional[str] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getYourAds + """ + + # pylint: disable=too-many-arguments + # API takes this many arguments, I can't change that + + params = {} + if country_code: + params["countrycode"] = country_code + if currency: + params["currency"] = currency + if trade_type: + params["trade_type"] = trade_type + if visible is not None and visible: + params["visible"] = "1" + elif visible is not None and not visible: + params["visible"] = "0" + if asset: + params["asset"] = asset + if payment_method_code: + params["payment_method_code"] = payment_method_code + if page: + params["page"] = page + + if len(params) == 0: + return await self._api_call(api_method="ads") + + return await self._api_call(api_method="ads", query_values=params) + + async def ad_get(self, ad_ids: List[str]) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getAdById and + https://agoradesk.com/api-docs/v1#operation/getAdsInBulk + """ + api_method = "ad-get" + params = None + ids = str(ad_ids)[1:-1].replace(" ", "").replace("'", "") + + if len(ad_ids) == 1: + api_method += f"/{ids}" + else: + params = {"ads": ids} + return await self._api_call(api_method=api_method, query_values=params) + + async def payment_methods( + self, country_code: Optional[str] = None + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/paymentMethods and + https://agoradesk.com/api-docs/v1#operation/countryHasPaymentMethod + """ + api_method = "payment_methods" + if country_code: + api_method += f"/{country_code}" + return await self._api_call(api_method=api_method) + + async def country_codes(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/countryCodes + """ + return await self._api_call(api_method="countrycodes") + + async def currencies(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/currencyCodes + """ + return await self._api_call(api_method="currencies") + + async def equation(self, price_equation: str, currency: str) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/priceFormula + """ + return await self._api_call( + api_method="equation", + http_method="POST", + query_values={ + "price_equation": price_equation, + "currency": currency, + }, + ) + + # Public ad search related API Methods + # ==================================== + + async def _generic_online( + self, + direction: str, + main_currency: str, + exchange_currency: str, + country_code: Optional[str] = None, + payment_method: Optional[str] = None, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + # pylint: disable=too-many-arguments + + add_to_api_method = "" + if country_code: + add_to_api_method = f"/{country_code}" + if payment_method: + add_to_api_method += f"/{payment_method}" + + params = self._generic_search_parameters(amount, page) + return await self._api_call( + api_method=f"{direction}-{main_currency}-online/" + f"{exchange_currency}{add_to_api_method}", + query_values=params, + ) + + @staticmethod + async def _generic_search_parameters(amount, page): + params = None + if amount and page is not None: + params = {"amount": f"{amount}"} + elif amount and page is not None: + params = {"amount": f"{amount}", "page": f"{page}"} + elif not amount and page is not None: + params = {"page": f"{page}"} + return params + + async def buy_monero_online( + self, + currency_code: str, + country_code: Optional[str] = None, + payment_method: Optional[str] = None, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getOnlineSellXmrByCurrencyCode and + https://agoradesk.com/api-docs/v1#operation/getOnlineSellXmrByCurrencyCodeAndCountryCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineSellXmrByCurrencyCodeAndPaymentMethodCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineSellXmrByCurrencyCodeAndCountryCodeAndPaymentMethodCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_online( + direction="buy", + main_currency="monero", + exchange_currency=currency_code, + country_code=country_code, + payment_method=payment_method, + amount=amount, + page=page, + ) + + async def buy_bitcoins_online( + self, + currency_code: str, + country_code: Optional[str] = None, + payment_method: Optional[str] = None, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getOnlineSellBtcByCurrencyCode + https://agoradesk.com/api-docs/v1#operation/getOnlineSellBtcByCurrencyCodeAndCountryCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineSellBtcByCurrencyCodeAndPaymentMethodCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineSellBtcByCurrencyCodeAndCountryCodeAndPaymentMethodCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_online( + direction="buy", + main_currency="bitcoins", + exchange_currency=currency_code, + country_code=country_code, + payment_method=payment_method, + amount=amount, + page=page, + ) + + async def sell_monero_online( + self, + currency_code: str, + country_code: Optional[str] = None, + payment_method: Optional[str] = None, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyXmrByCurrencyCode + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyXmrByCurrencyCodeAndCountryCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyXmrByCurrencyCodeAndPaymentMethodCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyXmrByCurrencyCodeAndCountryCodeAndPaymentMethodCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_online( + direction="sell", + main_currency="monero", + exchange_currency=currency_code, + country_code=country_code, + payment_method=payment_method, + amount=amount, + page=page, + ) + + async def sell_bitcoins_online( + self, + currency_code: str, + country_code: Optional[str] = None, + payment_method: Optional[str] = None, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyBtcByCurrencyCode + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyBtcByCurrencyCodeAndCountryCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyBtcByCurrencyCodeAndPaymentMethodCode and # noqa: E501 pylint: disable=line-too-long + https://agoradesk.com/api-docs/v1#operation/getOnlineBuyBtcByCurrencyCodeAndCountryCodeAndPaymentMethodCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_online( + direction="sell", + main_currency="bitcoins", + exchange_currency=currency_code, + country_code=country_code, + payment_method=payment_method, + amount=amount, + page=page, + ) + + async def _generic_cash( + self, + direction: str, + main_currency: str, + exchange_currency: str, + country_code: str, + lat: str, + lon: str, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + # pylint: disable=too-many-arguments + + params = self._generic_search_parameters(amount, page) + + return await self._api_call( + api_method=f"{direction}-{main_currency}-with-cash/" + f"{exchange_currency}/{country_code}/{lat}/{lon}", + query_values=params, + ) + + async def buy_monero_with_cash( + self, + currency_code: str, + country_code: str, + lat: str, + lon: str, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getLocalSellXmrByCurrencyCodeAndCountryCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_cash( + direction="buy", + main_currency="monero", + exchange_currency=currency_code, + country_code=country_code, + lat=lat, + lon=lon, + amount=amount, + page=page, + ) + + async def buy_bitcoins_with_cash( + self, + currency_code: str, + country_code: str, + lat: str, + lon: str, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getLocalSellBtcByCurrencyCodeAndCountryCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_cash( + direction="buy", + main_currency="bitcoins", + exchange_currency=currency_code, + country_code=country_code, + lat=lat, + lon=lon, + amount=amount, + page=page, + ) + + async def sell_monero_with_cash( + self, + currency_code: str, + country_code: str, + lat: str, + lon: str, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getLocalBuyXmrByCurrencyCodeAndCountryCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_cash( + direction="sell", + main_currency="monero", + exchange_currency=currency_code, + country_code=country_code, + lat=lat, + lon=lon, + amount=amount, + page=page, + ) + + async def sell_bitcoins_with_cash( + self, + currency_code: str, + country_code: str, + lat: str, + lon: str, + amount: Optional[float] = None, + page: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getLocalBuyBtcByCurrencyCodeAndCountryCode # noqa: E501 pylint: disable=line-too-long + """ + + # pylint: disable=too-many-arguments + + return self._generic_cash( + direction="sell", + main_currency="bitcoins", + exchange_currency=currency_code, + country_code=country_code, + lat=lat, + lon=lon, + amount=amount, + page=page, + ) + + # Statistics related API Methods + # ============================== + + async def moneroaverage( + self, currency: Optional[str] = "ticker-all-currencies" + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getXmrTicker and + https://agoradesk.com/api-docs/v1#operation/getXmrTickerByCurrencyCode + """ + return await self._api_call(api_method=f"moneroaverage/{currency}") + + # Wallet related API Methods + # =========================== + + async def wallet(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getBtcWallet + """ + return await self._api_call(api_method="wallet") + + async def wallet_balance(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getBtcWalletBalance + """ + return await self._api_call(api_method="wallet-balance") + + async def wallet_xmr(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getXmrWallet + """ + return await self._api_call(api_method="wallet/XMR") + + async def wallet_balance_xmr(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getXmrWalletBalance + """ + return await self._api_call(api_method="wallet-balance/XMR") + + async def wallet_addr(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getBtcAddress + """ + return await self._api_call(api_method="wallet-addr") + + async def wallet_addr_xmr(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getXMRAddress + """ + return await self._api_call(api_method="wallet-addr/XMR") + + async def fees(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getBtcFee + """ + return await self._api_call(api_method="fees") + + async def fees_xmr(self) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/getXmrFee + """ + return await self._api_call(api_method="fees/XMR") + + async def wallet_send( + self, + address: str, + amount: float, + password: str, + fee_level: str, + otp: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/withdrawBtc + """ + # pylint: disable=too-many-arguments + + params = { + "address": address, + "amount": amount, + "password": password, + "fee_level": fee_level, + } + if otp: + params["otp"] = otp + + return await self._api_call( + api_method="wallet-send", http_method="POST", query_values=params + ) + + async def wallet_send_xmr( + self, + address: str, + amount: float, + password: str, + # fee_level: str, + otp: Optional[int] = None, + ) -> Dict[str, Any]: + """See Agoradesk API. + + https://agoradesk.com/api-docs/v1#operation/withdrawXmr + """ + # pylint: disable=too-many-arguments + + params = { + "address": address, + "amount": amount, + "password": password, + # "fee_level": fee_level, + } + if otp: + params["otp"] = otp + + return await self._api_call( + api_method="wallet-send/XMR", + http_method="POST", + query_values=params, + ) diff --git a/core/forms.py b/core/forms.py index 357b27b..1f44684 100644 --- a/core/forms.py +++ b/core/forms.py @@ -4,7 +4,7 @@ from django.core.exceptions import FieldDoesNotExist from django.forms import ModelForm from mixins.restrictions import RestrictedFormMixin -from .models import Aggregator, NotificationSettings, User +from .models import Aggregator, NotificationSettings, Platform, User # flake8: noqa: E501 @@ -73,3 +73,58 @@ class AggregatorForm(RestrictedFormMixin, ModelForm): "poll_interval": "The interval in seconds to poll the aggregator service.", "enabled": "Whether or not the aggregator connection is enabled.", } + + +class PlatformForm(RestrictedFormMixin, ModelForm): + def __init__(self, *args, **kwargs): + super(PlatformForm, self).__init__(*args, **kwargs) + upper = ["usd", "otp"] + for field in self.fields: + for up in upper: + if up in self.fields[field].label: + self.fields[field].label = self.fields[field].label.replace( + up, up.upper() + ) + + class Meta: + model = Platform + fields = ( + "name", + "service", + "token", + "password", + "otp_token", + "username", + "send", + "cheat", + "dummy", + "cheat_interval_seconds", + "margin", + "max_margin", + "min_margin", + "min_trade_size_usd", + "max_trade_size_usd", + "accept_within_usd", + "no_reference_amount_check_max_usd", + "enabled", + ) + help_texts = { + "name": "The name of the platform connection.", + "service": "The platform service to use.", + "token": "The JWT auth token.", + "password": "Account password", + "otp_token": "The OTP secret key.", + "username": "Account username", + "send": "Whether or not to send messages on new trades.", + "cheat": "Whether or not to run the Autoprice cheat.", + "dummy": "When enabled, the trade escrow feature will be disabled.", + "cheat_interval_seconds": "The interval in seconds to run the Autoprice cheat.", + "margin": "The current margin. Only valid for initial ads post. Autoprice will override this.", + "max_margin": "The maximum margin to use.", + "min_margin": "The minimum margin to use.", + "min_trade_size_usd": "The minimum trade size in USD.", + "max_trade_size_usd": "The maximum trade size in USD.", + "accept_within_usd": "When a trade is wrong by less than this amount, it will be accepted.", + "no_reference_amount_check_max_usd": "When ticked, when no reference was found and a trade is higher than this amount, we will not accept payment even if it is the only one with this amount.", + "enabled": "Whether or not the platform connection is enabled.", + } diff --git a/core/lib/antifraud.py b/core/lib/antifraud.py new file mode 100644 index 0000000..a675bd2 --- /dev/null +++ b/core/lib/antifraud.py @@ -0,0 +1,111 @@ +# Project imports +from core.lib import db, notify +from core.util import logs + +log = logs.get_logger("antifraud") + + +class AntiFraud(object): + async def add_bank_sender(self, platform, platform_buyer, bank_sender): + """ + Add the bank senders into Redis. + :param platform: name of the platform - freeform + :param platform_buyer: the username of the buyer on the platform + :param bank_sender: the sender name from the bank + """ + key = f"namemap.{platform}.{platform_buyer}" + await db.r.sadd(key, bank_sender) + + async def get_previous_senders(self, platform, platform_buyer): + """ + Get all the previous bank sender names for the given buyer on the platform. + :param platform: name of the platform - freeform + :param platform_buyer: the username of the buyer on the platform + :return: set of previous buyers + :rtype: set + """ + key = f"namemap.{platform}.{platform_buyer}" + senders = await db.r.smembers(key) + if not senders: + return None + senders = db.convert(senders) + return senders + + async def check_valid_sender( + self, reference, platform, bank_sender, platform_buyer + ): + """ + Check that either: + * The platform buyer has never had a recognised transaction before + * The bank sender name matches a previous transaction from the platform buyer + :param reference: the trade reference + :param platform: name of the platform - freeform + :param bank_sender: the sender of the bank transaction + :param platform_buyer: the username of the buyer on the platform + :return: whether the sender is valid + :rtype: bool + """ + senders = await self.get_previous_senders(platform, platform_buyer) + if senders is None: # no senders yet, assume it's valid + return True + if platform_buyer in senders: + return True + self.ux.notify.notify_sender_name_mismatch( + reference, platform_buyer, bank_sender + ) + title = "Sender name mismatch" + message = ( + f"Sender name mismatch for {reference}:\n" + f"Platform buyer: {platform_buyer}" + f"Bank sender: {bank_sender}" + ) + # await notify.sendmsg(self.instance.) # TODO + return False + + async def check_tx_sender(self, tx, reference): + """ + Check whether the sender of a given transaction is authorised based on the previous + transactions of the username that originated the trade reference. + :param tx: the transaction ID + :param reference: the trade reference + """ + stored_trade = await db.get_ref(reference) + if not stored_trade: + return None + stored_tx = await db.get_tx(tx) + if not stored_tx: + return None + bank_sender = stored_tx["sender"] + platform_buyer = stored_trade["buyer"] + platform = stored_trade["subclass"] + is_allowed = await self.check_valid_sender( + reference, platform, bank_sender, platform_buyer + ) + if is_allowed is True: + return True + return False + + # def user_verification_successful(self, uid): + # """ + # A user has successfully completed verification. + # """ + # self.log.info(f"User has completed verification: {uid}") + # trade_list = self.markets.find_trades_by_uid(uid) + # for platform, trade_id, reference, currency in trade_list: + # self.markets.send_bank_details(platform, currency, trade_id) + # self.markets.send_reference(platform, trade_id, reference) + + # def send_verification_url(self, platform, uid, trade_id): + # send_setting, post_message = self.markets.get_send_settings(platform) + # if send_setting == "1": + # auth_url = self.ux.verify.create_applicant_and_get_link(uid) + # if platform == "lbtc": + # auth_url = auth_url.replace("https://", "") # hack + # post_message( + # trade_id, + # f"Hi! To continue the trade, please complete the verification form: {auth_url}", + # ) + + +if __name__ == "__main__": + antifraud = AntiFraud() diff --git a/core/lib/db.py b/core/lib/db.py index 70e5c83..80fffbc 100644 --- a/core/lib/db.py +++ b/core/lib/db.py @@ -2,7 +2,7 @@ from redis import asyncio as aioredis from core.util import logs -log = logs.get_logger("scheduling") +log = logs.get_logger("db") r = aioredis.from_url("redis://redis:6379", db=0) @@ -22,20 +22,20 @@ def convert(data): return data -def get_refs(): +async def get_refs(): """ Get all reference IDs for trades. :return: list of trade IDs :rtype: list """ references = [] - ref_keys = r.keys("trade.*.reference") + ref_keys = await r.keys("trade.*.reference") for key in ref_keys: references.append(r.get(key)) return convert(references) -def tx_to_ref(tx): +async def tx_to_ref(tx): """ Convert a trade ID to a reference. :param tx: trade ID @@ -43,16 +43,16 @@ def tx_to_ref(tx): :return: reference :rtype: string """ - refs = get_refs() + refs = await get_refs() for reference in refs: - ref_data = convert(r.hgetall(f"trade.{reference}")) + ref_data = convert(await r.hgetall(f"trade.{reference}")) if not ref_data: continue if ref_data["id"] == tx: return reference -def ref_to_tx(reference): +async def ref_to_tx(reference): """ Convert a reference to a trade ID. :param reference: trade reference @@ -60,27 +60,27 @@ def ref_to_tx(reference): :return: trade ID :rtype: string """ - ref_data = convert(r.hgetall(f"trade.{reference}")) + ref_data = convert(await r.hgetall(f"trade.{reference}")) if not ref_data: return False return ref_data["id"] -def get_ref_map(): +async def get_ref_map(): """ Get all reference IDs for trades. :return: dict of references keyed by TXID :rtype: dict """ references = {} - ref_keys = r.keys("trade.*.reference") + ref_keys = await r.keys("trade.*.reference") for key in ref_keys: tx = convert(key).split(".")[1] - references[tx] = r.get(key) + references[tx] = await r.get(key) return convert(references) -def get_ref(reference): +async def get_ref(reference): """ Get the trade information for a reference. :param reference: trade reference @@ -88,7 +88,7 @@ def get_ref(reference): :return: dict of trade information :rtype: dict """ - ref_data = r.hgetall(f"trade.{reference}") + ref_data = await r.hgetall(f"trade.{reference}") ref_data = convert(ref_data) if "subclass" not in ref_data: ref_data["subclass"] = "agora" @@ -97,7 +97,7 @@ def get_ref(reference): return ref_data -def get_tx(tx): +async def get_tx(tx): """ Get the transaction information for a transaction ID. :param reference: trade reference @@ -105,31 +105,31 @@ def get_tx(tx): :return: dict of trade information :rtype: dict """ - tx_data = r.hgetall(f"tx.{tx}") + tx_data = await r.hgetall(f"tx.{tx}") tx_data = convert(tx_data) if not tx_data: return False return tx_data -def get_subclass(reference): - obj = r.hget(f"trade.{reference}", "subclass") +async def get_subclass(reference): + obj = await r.hget(f"trade.{reference}", "subclass") subclass = convert(obj) return subclass -def del_ref(reference): +async def del_ref(reference): """ Delete a given reference from the Redis database. :param reference: trade reference to delete :type reference: string """ - tx = ref_to_tx(reference) - r.delete(f"trade.{reference}") - r.delete(f"trade.{tx}.reference") + tx = await ref_to_tx(reference) + await r.delete(f"trade.{reference}") + await r.delete(f"trade.{tx}.reference") -def cleanup(subclass, references): +async def cleanup(subclass, references): """ Reconcile the internal reference database with a given list of references. Delete all internal references not present in the list and clean up artifacts. @@ -137,14 +137,44 @@ def cleanup(subclass, references): :type references: list """ messages = [] - for tx, reference in get_ref_map().items(): + for tx, reference in await get_ref_map().items(): if reference not in references: - if get_subclass(reference) == subclass: + if await get_subclass(reference) == subclass: logmessage = ( f"[{reference}] ({subclass}): Archiving trade reference. TX: {tx}" ) messages.append(logmessage) log.info(logmessage) - r.rename(f"trade.{tx}.reference", f"archive.trade.{tx}.reference") - r.rename(f"trade.{reference}", f"archive.trade.{reference}") + await r.rename(f"trade.{tx}.reference", f"archive.trade.{tx}.reference") + await r.rename(f"trade.{reference}", f"archive.trade.{reference}") return messages + + +async 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 = await 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 = await 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: + log.error( + f"Find trade returned multiple results for TXID {txid}: {matching_refs}" + ) + return False + return matching_refs[0] diff --git a/core/lib/money.py b/core/lib/money.py new file mode 100644 index 0000000..1dedff8 --- /dev/null +++ b/core/lib/money.py @@ -0,0 +1,497 @@ +# Twisted imports +import logging +from datetime import datetime + +import urllib3 + +# Other library imports +from aiocoingecko import AsyncCoinGeckoAPISession +from django.conf import settings +from elasticsearch import AsyncElasticsearch +from forex_python.converter import CurrencyRates + +# TODO: secure ES traffic properly +urllib3.disable_warnings() + +tracer = logging.getLogger("opensearch") +tracer.setLevel(logging.CRITICAL) +tracer = logging.getLogger("elastic_transport.transport") +tracer.setLevel(logging.CRITICAL) + + +class Money(object): + """ + Generic class for handling money-related matters that aren't Revolut or Agora. + """ + + def __init__(self): + """ + Initialise the Money object. + Set the logger. + Initialise the CoinGecko API. + """ + print("MONEY INIT") + self.cr = CurrencyRates() + self.cg = AsyncCoinGeckoAPISession() + auth = (settings.ELASTICSEARCH_USERNAME, settings.ELASTICSEARCH_PASSWORD) + client = AsyncElasticsearch( + settings.ELASTICSEARCH_HOST, http_auth=auth, verify_certs=False + ) + self.es = client + + async def run_checks_in_thread(self): + """ + Run all the balance checks that output into ES in another thread. + """ + total = await self.get_total() + remaining = await self.get_remaining() + profit = await self.get_profit() + profit_with_trades = await self.get_profit(True) + open_trades = await self.get_open_trades_usd() + total_remaining = await self.get_total_remaining() + total_with_trades = await self.get_total_with_trades() + # This will make them all run concurrently, hopefully not hitting rate limits + for x in ( + total, + remaining, + profit, + profit_with_trades, + open_trades, + total_remaining, + total_with_trades, + ): + yield x + + # def setup_loops(self): + # """ + # Set up the LoopingCalls to get the balance so we have data in ES. + # """ + # if settings.ES.Enabled == "1" or settings.Logstash.Enabled == "1": + # self.lc_es_checks = LoopingCall(self.run_checks_in_thread) + # delay = int(settings.ES.RefreshSec) + # self.lc_es_checks.start(delay) + # if settings.ES.Enabled == "1": + # self.agora.es = self.es + # self.lbtc.es = self.es + + async def write_to_es(self, msgtype, cast): + cast["type"] = "money" + cast["ts"] = str(datetime.now().isoformat()) + cast["xtype"] = msgtype + cast["user_id"] = self.instance.user.id + cast["platform_id"] = self.instance.id + await self.es.index(index=settings.ELASTICSEARCH_INDEX, body=cast) + + async def lookup_rates(self, platform, ads, rates=None): + """ + Lookup the rates for a list of public ads. + """ + if not rates: + rates = await self.cg.get_price( + ids=["monero", "bitcoin"], + vs_currencies=self.markets.get_all_currencies(platform), + ) + # Set the price based on the asset + for ad in ads: + if ad[4] == "XMR": + coin = "monero" + elif ad[4] == "BTC": + coin = "bitcoin" # No s here + currency = ad[5] + base_currency_price = rates[coin][currency.lower()] + price = float(ad[2]) + rate = round(price / base_currency_price, 2) + ad.append(rate) + # TODO: sort? + return sorted(ads, key=lambda x: x[2]) + + async def get_rates_all(self): + """ + Get all rates that pair with USD. + :return: dictionary of USD/XXX rates + :rtype: dict + """ + rates = await self.cr.get_rates("USD") + return rates + + async def get_acceptable_margins(self, platform, currency, amount): + """ + Get the minimum and maximum amounts we would accept a trade for. + :param currency: currency code + :param amount: amount + :return: (min, max) + :rtype: tuple + """ + sets = util.get_settings(platform) + rates = await self.get_rates_all() + if currency == "USD": + min_amount = amount - float(sets.AcceptableUSDMargin) + max_amount = amount + float(sets.AcceptableUSDMargin) + return (min_amount, max_amount) + amount_usd = amount / rates[currency] + min_usd = amount_usd - float(sets.AcceptableUSDMargin) + max_usd = amount_usd + float(sets.AcceptableUSDMargin) + min_local = min_usd * rates[currency] + max_local = max_usd * rates[currency] + return (min_local, max_local) + + async def get_minmax(self, platform, asset, currency): + sets = util.get_settings(platform) + rates = await self.get_rates_all() + if currency not in rates and not currency == "USD": + self.log.error(f"Can't create ad without rates: {currency}") + return + if asset == "XMR": + min_usd = float(sets.MinUSDXMR) + max_usd = float(sets.MaxUSDXMR) + elif asset == "BTC": + min_usd = float(sets.MinUSDBTC) + max_usd = float(sets.MaxUSDBTC) + if currency == "USD": + min_amount = min_usd + max_amount = max_usd + else: + min_amount = rates[currency] * min_usd + max_amount = rates[currency] * max_usd + + return (min_amount, max_amount) + + async def to_usd(self, amount, currency): + if currency == "USD": + return float(amount) + else: + rates = await self.get_rates_all() + return float(amount) / rates[currency] + + async def multiple_to_usd(self, currency_map): + """ + Convert multiple curencies to USD while saving API calls. + """ + rates = await self.get_rates_all() + cumul = 0 + for currency, amount in currency_map.items(): + if currency == "USD": + cumul += float(amount) + else: + cumul += float(amount) / rates[currency] + return cumul + + async def get_profit(self, trades=False): + """ + Check how much total profit we have made. + :return: profit in USD + :rtype: float + """ + total_usd = await self.get_total_usd() + if not total_usd: + return False + if trades: + trades_usd = await self.get_open_trades_usd() + total_usd += trades_usd + + profit = total_usd - float(settings.Money.BaseUSD) + if trades: + cast_es = { + "profit_trades_usd": profit, + } + else: + cast_es = { + "profit_usd": profit, + } + + await self.write_to_es("get_profit", cast_es) + return profit + + async def get_total_usd(self): + """ + Get total USD in all our accounts, bank and trading. + :return: value in USD + :rtype float: + """ + total_sinks_usd = await self.sinks.get_total_usd() + agora_wallet_xmr = await self.agora.api.wallet_balance_xmr() + agora_wallet_btc = await self.agora.api.wallet_balance() + # lbtc_wallet_btc = await self.lbtc.api.wallet_balance() + if not agora_wallet_xmr["success"]: + return False + if not agora_wallet_btc["success"]: + return False + # if not lbtc_wallet_btc["success"]: + # return False + if not agora_wallet_xmr["response"]: + return False + if not agora_wallet_btc["response"]: + return False + # if not lbtc_wallet_btc["response"]: + # return False + total_xmr_agora = agora_wallet_xmr["response"]["data"]["total"]["balance"] + total_btc_agora = agora_wallet_btc["response"]["data"]["total"]["balance"] + # total_btc_lbtc = lbtc_wallet_btc["response"]["data"]["total"]["balance"] + # Get the XMR -> USD exchange rate + xmr_usd = await self.cg.get_price(ids="monero", vs_currencies=["USD"]) + + # Get the BTC -> USD exchange rate + btc_usd = await self.cg.get_price(ids="bitcoin", vs_currencies=["USD"]) + + # Convert the Agora BTC total to USD + total_usd_agora_btc = float(total_btc_agora) * btc_usd["bitcoin"]["usd"] + + # Convert the LBTC BTC total to USD + # total_usd_lbtc_btc = float(total_btc_lbtc) * btc_usd["bitcoin"]["usd"] + + # Convert the Agora XMR total to USD + total_usd_agora_xmr = float(total_xmr_agora) * xmr_usd["monero"]["usd"] + + # Add it all up + total_usd_agora = total_usd_agora_xmr + total_usd_agora_btc + # total_usd_lbtc = total_usd_lbtc_btc + total_usd = total_usd_agora + total_sinks_usd + # total_usd_lbtc + + cast_es = { + "price_usd": total_usd, + "total_usd_agora_xmr": total_usd_agora_xmr, + "total_usd_agora_btc": total_usd_agora_btc, + # "total_usd_lbtc_btc": total_usd_lbtc_btc, + "total_xmr_agora": total_xmr_agora, + "total_btc_agora": total_btc_agora, + # "total_btc_lbtc": total_btc_lbtc, + "xmr_usd": xmr_usd["monero"]["usd"], + "btc_usd": btc_usd["bitcoin"]["usd"], + "total_sinks_usd": total_sinks_usd, + "total_usd_agora": total_usd_agora, + } + await self.write_to_es("get_total_usd", cast_es) + return total_usd + + # TODO: possibly refactor this into smaller functions which don't return as much + # check if this is all really needed in the corresponding withdraw function + async def get_total(self): + """ + Get all the values corresponding to the amount of money we hold. + :return: ((total SEK, total USD, total GBP), + (total XMR USD, total BTC USD), + (total XMR, total BTC)) + :rtype: tuple(tuple(float, float, float), + tuple(float, float), + tuple(float, float)) + """ + total_sinks_usd = await self.sinks.get_total_usd() + agora_wallet_xmr = await self.agora.api.wallet_balance_xmr() + agora_wallet_btc = await self.agora.api.wallet_balance() + # lbtc_wallet_btc = await self.lbtc.api.wallet_balance() + if not agora_wallet_xmr["success"]: + return False + if not agora_wallet_btc["success"]: + return False + # if not lbtc_wallet_btc["success"]: + # return False + if not agora_wallet_xmr["response"]: + return False + if not agora_wallet_btc["response"]: + return False + # if not lbtc_wallet_btc["response"]: + # return False + total_xmr_agora = agora_wallet_xmr["response"]["data"]["total"]["balance"] + total_btc_agora = agora_wallet_btc["response"]["data"]["total"]["balance"] + # total_btc_lbtc = lbtc_wallet_btc["response"]["data"]["total"]["balance"] + # Get the XMR -> USD exchange rate + xmr_usd = self.cg.get_price(ids="monero", vs_currencies=["USD"]) + + # Get the BTC -> USD exchange rate + btc_usd = self.cg.get_price(ids="bitcoin", vs_currencies=["USD"]) + + # Convert the Agora XMR total to USD + total_usd_agora_xmr = float(total_xmr_agora) * xmr_usd["monero"]["usd"] + + # Convert the Agora BTC total to USD + total_usd_agora_btc = float(total_btc_agora) * btc_usd["bitcoin"]["usd"] + + # Convert the LBTC BTC total to USD + # total_usd_lbtc_btc = float(total_btc_lbtc) * btc_usd["bitcoin"]["usd"] + + # Add it all up + total_usd_agora = total_usd_agora_xmr + total_usd_agora_btc + # total_usd_lbtc = total_usd_lbtc_btc + total_usd = total_usd_agora + total_sinks_usd + # total_usd_lbtc + + total_btc_usd = total_usd_agora_btc # + total_usd_lbtc_btc + total_xmr_usd = total_usd_agora_xmr + + total_xmr = total_xmr_agora + total_btc = total_btc_agora + # total_btc_lbtc + + # Convert the total USD price to GBP and SEK + rates = await self.get_rates_all() + price_sek = rates["SEK"] * total_usd + price_usd = total_usd + price_gbp = rates["GBP"] * total_usd + + cast = ( + ( + price_sek, + price_usd, + price_gbp, + ), # Total prices in our 3 favourite currencies + ( + total_xmr_usd, + total_btc_usd, + ), # Total USD balance in only Agora + (total_xmr, total_btc), + ) # Total XMR and BTC balance in Agora + + cast_es = { + "price_sek": price_sek, + "price_usd": price_usd, + "price_gbp": price_gbp, + "total_usd_agora_xmr": total_usd_agora_xmr, + "total_usd_agora_btc": total_usd_agora_btc, + # "total_usd_lbtc_btc": total_usd_lbtc_btc, + "total_xmr_agora": total_xmr_agora, + "total_btc_agora": total_btc_agora, + # "total_btc_lbtc": total_btc_lbtc, + "xmr_usd": xmr_usd["monero"]["usd"], + "btc_usd": btc_usd["bitcoin"]["usd"], + "total_sinks_usd": total_sinks_usd, + "total_usd_agora": total_usd_agora, + } + await self.write_to_es("get_total", cast_es) + return cast + + async def get_remaining(self): + """ + Check how much profit we need to make in order to withdraw. + :return: profit remaining in USD + :rtype: float + """ + total_usd = await self.get_total_usd() + if not total_usd: + return False + + withdraw_threshold = float(settings.Money.BaseUSD) + float( + settings.Money.WithdrawLimit + ) + remaining = withdraw_threshold - total_usd + cast_es = { + "remaining_usd": remaining, + } + await self.write_to_es("get_remaining", cast_es) + return remaining + + async def open_trades_usd_parse_dash(self, platform, dash, rates): + cumul_usd = 0 + for contact_id, contact in dash.items(): + # We need created at in order to look up the historical prices + created_at = contact["data"]["created_at"] + + # Reformat the date how CoinGecko likes + # 2022-05-02T11:17:14+00:00 + if "+" in created_at: + date_split = created_at.split("+") + date_split[1].replace(".", "") + date_split[1].replace(":", "") + created_at = "+".join(date_split) + date_parsed = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%S%z") + else: + date_parsed = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%S.%fZ") + + date_formatted = date_parsed.strftime("%d-%m-%Y") + + # Get the historical rates for the right asset, extract the price + if platform == "agora": + asset = contact["data"]["advertisement"]["asset"] + elif platform == "lbtc": + asset = "BTC" + if asset == "XMR": + amount_crypto = contact["data"]["amount_xmr"] + history = await self.cg.get_coin_history_by_id( + id="monero", date=date_formatted + ) + if "market_data" not in history: + return False + crypto_usd = float(history["market_data"]["current_price"]["usd"]) + elif asset == "BTC": + amount_crypto = contact["data"]["amount_btc"] + history = await self.cg.get_coin_history_by_id( + id="bitcoin", date=date_formatted + ) + crypto_usd = float(history["market_data"]["current_price"]["usd"]) + # Convert crypto to fiat + amount = float(amount_crypto) * crypto_usd + currency = contact["data"]["currency"] + if not contact["data"]["is_selling"]: + continue + if currency == "USD": + cumul_usd += float(amount) + else: + rate = rates[currency] + amount_usd = float(amount) / rate + cumul_usd += amount_usd + return cumul_usd + + async def get_open_trades_usd(self): + """ + Get total value of open trades in USD. + :return: total trade value + :rtype: float + """ + dash_agora = await self.agora.wrap_dashboard() + # dash_lbtc = self.lbtc.wrap_dashboard() + # dash_lbtc = yield dash_lbtc + if dash_agora is False: + return False + # if dash_lbtc is False: + # return False + + rates = await self.get_rates_all() + cumul_usd_agora = await self.open_trades_usd_parse_dash( + "agora", dash_agora, rates + ) + # cumul_usd_lbtc = await self.open_trades_usd_parse_dash("lbtc", dash_lbtc, + # rates) + cumul_usd = cumul_usd_agora # + cumul_usd_lbtc + + cast_es = { + "trades_usd": cumul_usd, + } + await self.write_to_es("get_open_trades_usd", cast_es) + return cumul_usd + + async def get_total_remaining(self): + """ + Check how much profit we need to make in order to withdraw, taking into account + open trade value. + :return: profit remaining in USD + :rtype: float + """ + total_usd = await self.get_total_usd() + total_trades_usd = await self.get_open_trades_usd() + if not total_usd: + return False + total_usd += total_trades_usd + withdraw_threshold = float(settings.Money.BaseUSD) + float( + settings.Money.WithdrawLimit + ) + remaining = withdraw_threshold - total_usd + + cast_es = { + "total_remaining_usd": remaining, + } + await self.write_to_es("get_total_remaining", cast_es) + return remaining + + async def get_total_with_trades(self): + total_usd = await self.get_total_usd() + if not total_usd: + return False + total_trades_usd = await self.get_open_trades_usd() + total_with_trades = total_usd + total_trades_usd + cast_es = { + "total_with_trades": total_with_trades, + } + await self.write_to_es("get_total_with_trades", cast_es) + return total_with_trades + + +money = Money() diff --git a/core/lib/notify.py b/core/lib/notify.py index 8d98c33..9326cb9 100644 --- a/core/lib/notify.py +++ b/core/lib/notify.py @@ -1,4 +1,4 @@ -import requests +import aiohttp from core.util import logs @@ -8,7 +8,7 @@ log = logs.get_logger(__name__) # Actual function to send a message to a topic -def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None): +async def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None): if url is None: url = NTFY_URL headers = {"Title": "Pluto"} @@ -18,15 +18,17 @@ def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None) headers["Priority"] = priority if tags: headers["Tags"] = tags - requests.post( - f"{url}/{topic}", - data=msg, - headers=headers, - ) + cast = { + "headers": headers, + "data": msg, + } + async with aiohttp.ClientSession() as session: + async with session.post(f"{url}/{topic}", **cast) as response: + response = await response.content() # Sendmsg helper to send a message to a user's notification settings -def sendmsg(user, *args, **kwargs): +async def sendmsg(user, *args, **kwargs): notification_settings = user.get_notification_settings() if notification_settings.ntfy_topic is None: @@ -35,4 +37,4 @@ def sendmsg(user, *args, **kwargs): else: topic = notification_settings.ntfy_topic - raw_sendmsg(*args, **kwargs, url=notification_settings.ntfy_url, topic=topic) + await raw_sendmsg(*args, **kwargs, url=notification_settings.ntfy_url, topic=topic) diff --git a/core/migrations/0008_platform.py b/core/migrations/0008_platform.py new file mode 100644 index 0000000..56c7616 --- /dev/null +++ b/core/migrations/0008_platform.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.7 on 2023-03-09 20:50 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_alter_aggregator_account_info_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Platform', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('service', models.CharField(choices=[('agora', 'Agora')], max_length=255)), + ('token', models.CharField(max_length=1024)), + ('password', models.CharField(max_length=1024)), + ('otp_token', models.CharField(blank=True, max_length=1024, null=True)), + ('username', models.CharField(max_length=255)), + ('send', models.BooleanField(default=True)), + ('cheat', models.BooleanField(default=False)), + ('dummy', models.BooleanField(default=False)), + ('cheat_interval_seconds', models.IntegerField(default=600)), + ('margin', models.FloatField(default=1.2)), + ('max_margin', models.FloatField(default=1.3)), + ('min_margin', models.FloatField(default=1.15)), + ('min_trade_size_usd', models.FloatField(default=10)), + ('max_trade_size_usd', models.FloatField(default=4000)), + ('accept_within_usd', models.FloatField(default=1)), + ('no_reference_amount_check_max_usd', models.FloatField(default=400)), + ('enabled', models.BooleanField(default=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/models.py b/core/models.py index cf2c4ed..13c4e77 100644 --- a/core/models.py +++ b/core/models.py @@ -10,6 +10,8 @@ log = logs.get_logger(__name__) SERVICE_CHOICES = (("nordigen", "Nordigen"),) +PLATFORM_SERVICE_CHOICES = (("agora", "Agora"),) + class User(AbstractUser): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -62,3 +64,68 @@ class Aggregator(models.Model): @property def client(self): pass + + @classmethod + def get_for_platform(cls, platform): + return cls.objects.filter(user=platform.user, enabled=True) + + @classmethod + def get_currencies_for_platform(cls, platform): + aggregators = Aggregator.get_for_platform(platform) + currencies = set() + for aggregator in aggregators: + for currency in aggregator.currencies: + currencies.add(currency) + + return list(currencies) + + @classmethod + def get_account_info_for_platform(cls, platform): + aggregators = Aggregator.get_for_platform(platform) + account_info = {} + for agg in aggregators: + for bank, accounts in agg.account_info.items(): + if bank not in account_info: + account_info[bank] = [] + for account in accounts: + account_info[bank].append(account) + return account_info + + +class Platform(models.Model): + """ + A connection to an arbitrage platform like AgoraDesk. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + service = models.CharField(max_length=255, choices=PLATFORM_SERVICE_CHOICES) + token = models.CharField(max_length=1024) + password = models.CharField(max_length=1024) + otp_token = models.CharField(max_length=1024, null=True, blank=True) + username = models.CharField(max_length=255) + + send = models.BooleanField(default=True) + cheat = models.BooleanField(default=False) + dummy = models.BooleanField(default=False) + cheat_interval_seconds = models.IntegerField(default=600) + + margin = models.FloatField(default=1.20) + max_margin = models.FloatField(default=1.30) + min_margin = models.FloatField(default=1.15) + min_trade_size_usd = models.FloatField(default=10) + max_trade_size_usd = models.FloatField(default=4000) + + accept_within_usd = models.FloatField(default=1) + no_reference_amount_check_max_usd = models.FloatField(default=400) + + enabled = models.BooleanField(default=True) + + @property + def currencies(self): + return Aggregator.get_currencies_for_platform(self) + + @property + def account_info(self): + return Aggregator.get_account_info_for_platform(self) diff --git a/core/templates/base.html b/core/templates/base.html index 9b9ba7f..ffa0857 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -262,7 +262,7 @@ Bank Aggregators - + Platform Connections diff --git a/core/templates/partials/platform-list.html b/core/templates/partials/platform-list.html new file mode 100644 index 0000000..c0a10d6 --- /dev/null +++ b/core/templates/partials/platform-list.html @@ -0,0 +1,81 @@ +{% load cache %} +{% load cachalot cache %} +{% get_last_invalidation 'core.Platform' as last %} +{% include 'mixins/partials/notify.html' %} +{# cache 600 objects_platforms request.user.id object_list type last #} + + + + + + + + + + {% for item in object_list %} + + + + + + + + + {% endfor %} + +
idusernameserviceenabledactions
+ + + + + + {{ item.user }}{{ item.name }}{{ item.get_service_display }} + {% if item.enabled %} + + + + {% else %} + + + + {% endif %} + +
+ + +
+
+{# endcache #} \ No newline at end of file diff --git a/core/views/platforms.py b/core/views/platforms.py new file mode 100644 index 0000000..dd5061c --- /dev/null +++ b/core/views/platforms.py @@ -0,0 +1,50 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponse +from django.shortcuts import render +from django.urls import reverse +from django.views import View +from mixins.views import ( # ObjectRead, + ObjectCreate, + ObjectDelete, + ObjectList, + ObjectUpdate, +) +from two_factor.views.mixins import OTPRequiredMixin + +# from core.clients.platforms.agora import AgoraClient +from core.forms import PlatformForm +from core.models import Platform +from core.util import logs + +# from core.views.helpers import synchronize_async_helper + +log = logs.get_logger(__name__) + + +class PlatformList(LoginRequiredMixin, OTPRequiredMixin, ObjectList): + list_template = "partials/platform-list.html" + model = Platform + page_title = "List of platform connections" + + list_url_name = "platforms" + list_url_args = ["type"] + + submit_url_name = "platform_create" + + +class PlatformCreate(LoginRequiredMixin, OTPRequiredMixin, ObjectCreate): + model = Platform + form_class = PlatformForm + + submit_url_name = "platform_create" + + +class PlatformUpdate(LoginRequiredMixin, OTPRequiredMixin, ObjectUpdate): + model = Platform + form_class = PlatformForm + + submit_url_name = "platform_update" + + +class PlatformDelete(LoginRequiredMixin, OTPRequiredMixin, ObjectDelete): + model = Platform diff --git a/requirements.txt b/requirements.txt index 16179b0..e2946f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,10 +25,8 @@ redis hiredis django-cachalot PyOTP -pycoingecko +aiocoingecko requests -arrow -httpx forex_python pyOpenSSL Klein @@ -36,3 +34,4 @@ ConfigObject aiohttp[speedups] elasticsearch[async] uvloop +arrow