You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1357 lines
50 KiB
Python

# Twisted/Klein imports
import asyncio
from abc import ABC
from datetime import datetime, timezone
from random import choices
from string import ascii_uppercase
from aiocoingecko import AsyncCoinGeckoAPISession
from django.conf import settings
from core.clients.platforms.api.agoradesk import AgoraDesk
from core.lib import 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)
if settings.DUMMY:
log.info(
"DUMMY: Dummy mode is enabled. Destructive commands will be printed."
)
async def call_method(self, method, *args, **kwargs):
"""
Call a method using the self.api object.
"""
if hasattr(self.api, method):
returned_429 = True
iterations = 0
throttled = 0
while returned_429 or iterations == 100:
response = await getattr(self.api, method)(*args, **kwargs)
if "status" in response:
if response["status"] == 429: # Too many requests
throttled += 1
sleep_time = pow(throttled, 1.9)
log.info(
(
f"Throttled {throttled} times while calling "
f"{method}, "
f"sleeping for {sleep_time} seconds"
)
)
await asyncio.sleep(sleep_time)
elif response["status"] == 400:
raise Exception(response)
else:
if throttled != 0:
log.info(
(
f"Finally successful after {throttled}"
f" attempts to call {method}"
)
)
returned_429 = False
throttled = 0
iterations += 1
return response
else:
raise Exception(f"Method {method} not found in {self.name} API.")
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):
if not dash:
dash = await self.call("dashboard")
dash_tmp = {}
if not dash:
return False
if dash["contact_count"] > 0:
for contact in dash["contact_list"]:
contact_id = contact["data"]["contact_id"]
reference = self.instance.contact_id_to_reference(str(contact_id))
contact["reference"] = reference
dash_tmp[contact_id] = contact
return dash_tmp
async def poll(self):
"""
Calls hooks to parse dashboard info and get all contact messages.
"""
dashboard_response = await self.call("dashboard")
await self.got_dashboard(dashboard_response)
# Get recent messages
messages = await self.api.recent_messages()
await self.got_recent_messages(messages)
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():
contact_id = str(contact_id)
reference = self.instance.contact_id_to_reference(contact_id)
if reference:
current_trades.append(reference)
buyer = contact["data"]["buyer"]["username"]
amount = contact["data"]["amount"]
if self.name == "agora":
asset = contact["data"]["advertisement"]["asset"]
elif self.name == "lbtc":
asset = "BTC"
provider = contact["data"]["advertisement"]["payment_method"]
ad_id = contact["data"]["advertisement"]["id"]
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
reference = await self.new_trade(
asset,
contact_id,
buyer,
currency,
amount,
amount_crypto,
provider,
ad_id,
)
if reference:
if reference not in current_trades:
current_trades.append(reference)
# Purge old trades from cache
if reference and reference not in current_trades:
current_trades.append(reference)
messages = self.instance.remove_trades_with_reference_not_in(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 = self.instance.trade_ids
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 = self.instance.contact_id_to_reference(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.instance.last_messages:
if (
not [user, message]
in self.instance.last_messages[reference]
):
await notify.sendmsg(
self.instance.user,
f"[{reference}] ({self.name}) <{user}> {message}",
title="New message",
)
# Append sent messages to last_messages so we don't send
# them again
self.instance.last_messages[reference].append(
[user, message]
)
else:
self.instance.last_messages[reference] = [[user, message]]
for x in messages_tmp[reference]:
await notify.sendmsg(
self.instance.user,
f"[{reference}] ({self.name}) <{user}> {message}",
title="New message",
)
# Purge old trades from cache
for ref in list(
self.instance.last_messages
): # We're removing from the list on the fly
if ref not in messages_tmp:
del self.instance.last_messages[ref]
self.instance.save()
return messages_tmp
async def enum_ad_ids(self, page=0):
if self.name == "lbtc" and page == 0:
page = 1
ads = await self.call("ads", page=page)
ads_total = []
if not ads["success"]:
return False
for ad in ads["ad_list"]:
ads_total.append(ad["data"]["ad_id"])
if "pagination" in ads:
if ads["pagination"]:
if "next" in ads["pagination"]:
if ads["pagination"]["next"] is not None:
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):
query_values = {"page": page}
if requested_asset:
query_values["asset"] = requested_asset
ads = await self.call("ads", page=page)
ads_total = []
if not ads["success"]:
return False
for ad in ads["ad_list"]:
asset = ad["data"]["asset"]
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:
if ads["pagination"]:
if "next" in ads["pagination"]:
if ads["pagination"]["next"] is not None:
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, provider, page=0):
log.debug(f"Enumerating public ads: {asset}/{currency}/{provider} ({page})")
to_return = []
if asset == "XMR":
ads = await self.call(
"buy_monero_online",
currency_code=currency,
payment_method=provider,
page=page,
)
elif asset == "BTC":
ads = await self.call(
"buy_bitcoins_online",
currency_code=currency,
payment_method=provider,
page=page,
)
else:
raise Exception("Unknown asset")
if not ads["success"]:
return False
found_us = False
for ad in ads["ad_list"]:
provider_ad = ad["data"]["online_provider"]
if self.name == "lbtc":
provider_test = self.map_provider(provider_ad)
else:
provider_test = provider
if provider_test != provider:
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"]
if username == self.instance.username:
found_us = True
temp_price = ad["data"]["temp_price"]
if ad["data"]["currency"] != currency:
continue
to_append = [ad_id, username, temp_price, provider_ad, asset, currency]
if to_append not in to_return:
to_return.append(to_append)
if found_us:
log.debug("Aborting fetch after finding our username")
return to_return
if "pagination" in ads:
if ads["pagination"]:
if "next" in ads["pagination"]:
if ads["pagination"]["next"] is not None:
page += 1
ads_iter = await self.enum_public_ads(
asset, currency, provider, 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 cheat(self, assets=None):
# Get all public ads for the given assets
log.debug(f"Running cheat for {self.instance.name}")
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.name, 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",
}
assets = assets or self.instance.ads_assets
# Get all currencies we have ads for, deduplicated
providers = providers or self.instance.ads_providers
currencies = currencies or self.instance.currencies
# We want to get the ads for each of these currencies and return the result
async with AsyncCoinGeckoAPISession() as cg:
rates = await cg.get_price(
ids="monero,bitcoin", vs_currencies=",".join(currencies)
)
for asset in assets:
for currency in currencies:
for provider in providers:
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, provider)
if not ads_list:
log.debug("Error getting ads list.")
continue
ads = await money.lookup_rates(self.name, 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.name,
"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:
if not settings.DUMMY:
rtrn = await self.api.ad_equation(ad_id, new_formula)
else:
log.debug(f"DUMMY: ad_equation({ad_id}, {new_formula})")
rtrn = {"success": True}
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"
)
)
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:
if not settings.DUMMY:
rtrn = await self.api.ad_delete(ad_id)
else:
log.debug(f"DUMMY: ad_delete({ad_id})")
rtrn = {"success": True}
return_ids.append(rtrn["success"])
return all(return_ids)
async def create_ad(
self,
asset,
countrycode,
currency,
provider,
payment_details,
ad,
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,
)
ad_text = self.format_ad(asset, currency, payment_details_text, ad)
min_amount, max_amount = await money.get_minmax(
self.instance.min_trade_size_usd,
self.instance.max_trade_size_usd,
asset,
currency,
)
price_formula = (
f"coingecko{asset.lower()}usd*"
f"usd{currency.lower()}*{self.instance.margin}"
)
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": ad.require_feedback_score,
}
form["asset"] = asset
form["payment_method_details"] = ad.payment_method_details
form["online_provider"] = provider
if visible is False:
form["visible"] = False
elif visible is True:
form["visible"] = True
if payment_details:
form["account_info"] = payment_details_text
form["msg"] = ad_text
if min_amount is not None:
form["min_amount"] = round(min_amount, 2)
if max_amount is not None:
form["max_amount"] = round(max_amount, 2)
if edit:
if not settings.DUMMY:
ad_response = await self.api.ad(ad_id=ad_id, **form)
else:
log.debug(f"DUMMY: ad({ad_id}, {form})")
ad_response = {"success": True}
if ad_response["success"]:
self.instance.platform_ad_ids[ad_id] = str(ad.id)
self.instance.save()
else:
if not settings.DUMMY:
ad_response = await self.api.ad_create(**form)
else:
log.debug(f"DUMMY: ad_create({form})")
ad_response = {"success": True}
if ad_response["success"]:
ad_id = ad_response["response"]["data"]["ad_id"]
self.instance.platform_ad_ids[ad_id] = str(ad.id)
self.instance.save()
return ad_response
async def dist_countries(self, ad, 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(ad, filter_asset))
print("Dist list", dist_list)
our_ads = await self.enum_ads()
(
supported_currencies,
account_info,
) = self.get_valid_account_details(ad)
# Let's get rid of the ad IDs and make it a tuple like dist_list
if our_ads in [None, False]:
log.error("Could not get our ads")
return False
our_ads = [(x[0], x[2], x[3], x[4]) for x in our_ads]
print("our_ads", our_ads)
to_return = []
for asset, countrycode, currency, provider in dist_list:
if (asset, countrycode, currency, provider) not in our_ads:
print("NOT IN OUR ADS", asset, countrycode, currency, provider)
if currency not in supported_currencies:
print("CURRENCY", currency, "NOT IN", supported_currencies)
continue
# Create the actual ad and pass in all the stuff
if not settings.DUMMY:
rtrn = await self.create_ad(
asset,
countrycode,
currency,
provider,
payment_details=account_info[currency],
ad=ad,
)
print("REAL CREATE AD RETURN", rtrn)
else:
log.debug(
f"DUMMY: create_ad({asset}, {countrycode}, {currency}, "
f"{provider}, {account_info[currency]}, {ad})"
)
rtrn = {"success": True}
# 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, ad):
"""
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(ad)
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 asset not in self.instance.ads_assets:
continue
if provider not in self.instance.ads_providers:
continue
if currency in supported_currencies:
if currency not in account_info:
continue
if not settings.DUMMY:
rtrn = await self.create_ad(
asset,
countrycode,
currency,
provider,
payment_details=account_info[currency],
ad=ad,
visible=ad.visible,
edit=True,
ad_id=ad_id,
)
else:
log.debug(
f"DUMMY: create_ad({asset}, {countrycode}, {currency}, "
f"{provider}, {account_info[currency]}, {ad}, "
f"{ad.visible}, True, {ad_id})"
)
rtrn = {"success": True}
# 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()
print("EXISTING", existing_ads)
_size = len(existing_ads)
print("SIZE", _size)
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 = []
print("repeated", repeated)
for ad_id, country, currency in repeated:
if not settings.DUMMY:
rtrn = await self.api.ad_delete(ad_id)
else:
log.debug(f"DUMMY: ad_delete({ad_id})")
rtrn = {"success": True}
actioned.append(rtrn["success"])
print("actioned", actioned)
return all(actioned)
async def release_trade_escrow(self, trade_id, reference):
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)
if not settings.DUMMY:
rtrn = await self.release_funds(trade_id)
else:
log.debug(f"DUMMY: release_funds({trade_id})")
rtrn = {"message": "OK"}
if rtrn["message"] == "OK":
if not settings.DUMMY:
await self.api.contact_message_post(
trade_id, "Thanks! Releasing now :)"
)
else:
log.debug(f"DUMMY: contact_message_post({trade_id}, 'Thanks!')")
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
async def update_trade_tx(self, stored_trade, tx_obj):
"""
Update a trade to point to a given transaction ID.
Return False if the transaction already has a mapped trade.
"""
if tx_obj.reconciled:
return False
if tx_obj in stored_trade.linked.all():
return False
stored_trade.linked.add(tx_obj)
stored_trade.save()
tx_obj.reconciled = True
tx_obj.save()
return True
async def reset_trade_tx(self, stored_trade, tx_obj):
"""
Remove a trade to point to a given transaction ID.
"""
if not tx_obj.reconciled:
return False
if tx_obj not in stored_trade.linked.all():
return False
stored_trade.linked.remove(tx_obj)
stored_trade.save()
tx_obj.reconciled = False
tx_obj.save()
return True
async def successful_release(self, trade, transaction):
"""
Called when a trade has been successfully released.
Increment the platform and requisition throughput by the trade value.
Currently only XMR is supported.
"""
if trade.asset != "XMR":
raise NotImplementedError("Only XMR is supported at the moment.")
# Increment the platform throughput
trade.platform.throughput += trade.amount_crypto
# Increment the requisition throughput
if transaction.requisition is not None:
transaction.requisition.throughput += trade.amount_crypto
async def successful_withdrawal(self):
platforms = self.instance.platforms.all()
aggregators = self.instance.aggregators.all()
for platform in platforms:
platform.throughput = 0
platform.save()
for aggregator in aggregators:
for requisition in aggregator.requisitions.all():
requisition.throughput = 0
requisition.save()
async def release_map_trade(self, stored_trade, tx_obj):
"""
Map a trade to a transaction and release if no other TX is
mapped to the same trade.
"""
platform_buyer = stored_trade.buyer
bank_sender = tx_obj.sender
trade_id = stored_trade.contact_id
is_updated = await self.update_trade_tx(stored_trade, tx_obj)
if is_updated is True:
# We mapped the trade successfully
await antifraud.add_bank_sender(platform_buyer, bank_sender)
released = await self.release_trade_escrow(trade_id, stored_trade.reference)
if not released:
# We failed to release the funds
# Set the TX back to not reconciled, so we can try this again
await self.reset_trade_tx(stored_trade, tx_obj)
return False
await self.successful_release(stored_trade, tx_obj)
return released
else:
# Already mapped
log.error(
f"Trade {stored_trade} already has a TX mapped, cannot map {tx_obj}."
)
return False
async def new_trade(
self,
asset,
trade_id,
buyer,
currency,
amount,
amount_crypto,
provider,
ad_id,
):
"""
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 = self.instance.contact_id_to_reference(trade_id)
if not existing_ref:
trade_cast = {
"contact_id": trade_id,
"reference": reference,
"buyer": buyer,
"amount_fiat": amount,
"amount_crypto": amount_crypto,
"asset": asset,
"currency": currency,
"provider": provider,
"ad_id": ad_id,
}
log.info(f"Storing trade information: {str(trade_cast)}")
self.instance.new_trade(trade_cast)
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.")
ad_obj = self.instance.get_ad(ad_id)
if ad_obj:
await self.send_bank_details(currency, trade_id, ad_obj)
if ad_obj.send_reference is True:
await self.send_reference(trade_id, reference)
else:
log.warning(f"Could not get ad object for {ad_id}.")
# return
if existing_ref:
return 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 = self.instance.references
matching_trades = []
for reference in refs:
ref_data = self.instance.get_trade_by_reference(reference)
tx_username = ref_data.buyer
trade_id = ref_data.contact_id
currency = ref_data.currency
if 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, trade_id, reference):
"""
Send the reference to a customer.
"""
if settings.DUMMY:
log.debug(f"DUMMY: Sending reference {reference} to {trade_id}")
return
if self.instance.send is True:
await self.api.contact_message_post(
trade_id,
f"When sending the payment please use reference code: {reference}",
)
async def send_bank_details(self, currency, trade_id, ad):
"""
Send the bank details to a trade.
"""
log.info(f"Sending bank details/reference for {trade_id}")
if self.instance.send is True:
account_info = self.get_matching_account_details(currency, ad)
formatted_account_info = self.format_payment_details(
currency, account_info, ad, real=True
)
if not formatted_account_info:
log.error(f"Payment info invalid: {formatted_account_info}")
return
if not settings.DUMMY:
await self.api.contact_message_post(
trade_id,
f"Payment details: \n{formatted_account_info}",
)
else:
log.debug(
f"DUMMY: contact_message_post({trade_id}, {formatted_account_info})"
)
def get_all_assets(self, platform):
raise Exception
def get_all_providers(self, platform):
raise Exception
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)
assets = self.instance.ads_assets
currencies = self.instance.currencies
providers = self.instance.ads_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, ad, filter_asset=None):
"""
Create a list for distribution of ads.
:return: generator of asset, countrycode, currency, provider
:rtype: generator of tuples
"""
dist_list_raw = ad.dist_list
dist_list_lines = dist_list_raw.splitlines()
distlist = []
for line in dist_list_lines:
l_currency, l_country = line.split()
distlist.append([l_currency, l_country])
# Iterate providers like REVOLUT, NATIONAL_BANK
for provider in ad.providers:
# Iterate assets like XMR, BTC
for asset in ad.assets:
# 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, ad=None):
currencies = self.instance.currencies
account_info = self.instance.account_info
if ad is not None:
account_whitelist = ad.account_whitelist
if account_whitelist:
whitelist = account_whitelist.splitlines()
else:
whitelist = None
currency_account_info_map = {}
# Nothing to see here...
for currency in currencies:
for bank, accounts in account_info.items():
for account in accounts:
if account["currency"] == currency:
if ad is not None:
if whitelist:
if account["account_id"] not in whitelist:
continue
currency_account_info_map[currency] = account["account_number"]
currency_account_info_map[currency]["bank"] = bank.split("_")[0]
currency_account_info_map[currency]["recipient"] = account[
"ownerName"
]
currency_account_info_map[currency]["aggregator_id"] = account[
"aggregator_id"
]
currency_account_info_map[currency]["requisition_id"] = account[
"requisition_id"
]
return (list(currency_account_info_map.keys()), currency_account_info_map)
def get_matching_account_details(self, currency, ad):
(
supported_currencies,
currency_account_info_map,
) = self.get_valid_account_details(ad)
if currency not in supported_currencies:
return False
return currency_account_info_map[currency]
async def sync_ad_visibility(self, currencies=None, account_info=None):
"""
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(self.platform)
# not_supported = [currency for currency in all_currencies if
# currency not in supported_currencies]
our_ads = await self.enum_ads()
# supported_ads = [ad for ad in our_ads if ad[3] in currencies]
not_supported_ads = [ad for ad in our_ads if ad[3] not in 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]
if not settings.DUMMY:
await self.create_ad(
asset,
countrycode,
currency,
provider,
payment_details=False,
ad=None,
visible=False,
edit=True,
ad_id=ad_id,
)
else:
log.debug(
(
f"DUMMY: create_ad({ad_id}, {asset}, {countrycode}, {currency},"
f" {provider}, False, None, False, True, {ad_id})"
)
)
def format_ad(self, asset, currency, payment_details_text, ad):
"""
Format the ad.
"""
ad_text = ad.text
# Substitute the currency
ad_text = ad_text.replace("$CURRENCY$", currency)
# Substitute the asset
ad_text = ad_text.replace("$ASSET$", asset)
# Substitute the payment details
ad_text = ad_text.replace("$PAYMENT$", payment_details_text)
# Strip extra tabs
ad_text = ad_text.replace("\\t", "\t")
return ad_text
def format_payment_details(self, currency, payment_details, ad, real=False):
"""
Format the payment details.
"""
if not payment_details:
return False
if real:
aggregator_id = payment_details["aggregator_id"]
requisition_id = payment_details["requisition_id"]
req = self.instance.get_requisition(aggregator_id, requisition_id)
if req:
payment = req.payment_details
else:
payment = ad.payment_details_real
else:
payment = ad.payment_details
payment_text = ""
for field, value in payment_details.items():
if field in ["aggregator_id", "requisition_id"]:
# Don't send these to the user
continue
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