From 75f04579ed685815329b9733e4366b9a07fa8f9b Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi <90936742+Reckless-Satoshi@users.noreply.github.com> Date: Mon, 1 May 2023 10:30:53 +0000 Subject: [PATCH] Refactor models into a module (#481) * Refactor models into a module * Rename Profile model as Robot * Underscore numeric literals --- api/admin.py | 53 +- api/lightning/node.py | 6 +- api/logics.py | 151 ++-- api/management/commands/telegram_watcher.py | 18 +- api/models.py | 758 -------------------- api/models/__init__.py | 8 + api/models/currency.py | 31 + api/models/ln_payment.py | 132 ++++ api/models/market_tick.py | 84 +++ api/models/onchain_payment.py | 120 ++++ api/models/order.py | 285 ++++++++ api/models/robot.py | 137 ++++ api/nick_generator/dicts/en/nouns.py | 4 +- api/nick_generator/nick_generator.py | 31 +- api/notifications.py | 76 +- api/serializers.py | 4 +- api/tasks.py | 38 +- api/views.py | 54 +- chat/consumers.py | 4 +- chat/models.py | 3 +- chat/views.py | 7 +- control/tasks.py | 6 +- 22 files changed, 1029 insertions(+), 981 deletions(-) delete mode 100644 api/models.py create mode 100644 api/models/__init__.py create mode 100644 api/models/currency.py create mode 100644 api/models/ln_payment.py create mode 100644 api/models/market_tick.py create mode 100644 api/models/onchain_payment.py create mode 100644 api/models/order.py create mode 100644 api/models/robot.py diff --git a/api/admin.py b/api/admin.py index c3bbd014..954fd738 100644 --- a/api/admin.py +++ b/api/admin.py @@ -6,14 +6,14 @@ from django.contrib.auth.models import Group, User from django_admin_relation_links import AdminChangeLinksMixin from api.logics import Logics -from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, Profile +from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, Robot admin.site.unregister(Group) admin.site.unregister(User) -class ProfileInline(admin.StackedInline): - model = Profile +class RobotInline(admin.StackedInline): + model = Robot can_delete = False fields = ("avatar_tag",) readonly_fields = ["avatar_tag"] @@ -23,22 +23,22 @@ class ProfileInline(admin.StackedInline): # extended users with avatars @admin.register(User) class EUserAdmin(AdminChangeLinksMixin, UserAdmin): - inlines = [ProfileInline] + inlines = [RobotInline] list_display = ( "avatar_tag", "id", - "profile_link", + "robot_link", "username", "last_login", "date_joined", "is_staff", ) list_display_links = ("id", "username") - change_links = ("profile",) + change_links = ("robot",) ordering = ("-id",) def avatar_tag(self, obj): - return obj.profile.avatar_tag() + return obj.robot.avatar_tag() @admin.register(Order) @@ -90,7 +90,16 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): "currency", "status", ) - search_fields = ["id", "amount", "min_amount", "max_amount"] + search_fields = [ + "id", + "reference", + "maker", + "taker", + "amount", + "min_amount", + "max_amount", + ] + readonly_fields = ["reference"] actions = [ "maker_wins", @@ -103,7 +112,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): def maker_wins(self, request, queryset): """ Solves a dispute on favor of the maker. - Adds Sats to compensations (earned_rewards) of the maker profile. + Adds Sats to compensations (earned_rewards) of the maker robot. """ for order in queryset: if ( @@ -120,8 +129,8 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): trade_sats = order.trade_escrow.num_satoshis order.status = Order.Status.TLD - order.maker.profile.earned_rewards = own_bond_sats + trade_sats - order.maker.profile.save() + order.maker.robot.earned_rewards = own_bond_sats + trade_sats + order.maker.robot.save() order.save() self.message_user( request, @@ -140,7 +149,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): def taker_wins(self, request, queryset): """ Solves a dispute on favor of the taker. - Adds Sats to compensations (earned_rewards) of the taker profile. + Adds Sats to compensations (earned_rewards) of the taker robot. """ for order in queryset: if ( @@ -157,8 +166,8 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): trade_sats = order.trade_escrow.num_satoshis order.status = Order.Status.MLD - order.taker.profile.earned_rewards = own_bond_sats + trade_sats - order.taker.profile.save() + order.taker.robot.earned_rewards = own_bond_sats + trade_sats + order.taker.robot.save() order.save() self.message_user( request, @@ -183,18 +192,18 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): order.status in [Order.Status.DIS, Order.Status.WFR] and order.is_disputed ): - order.maker_bond.sender.profile.earned_rewards += ( + order.maker_bond.sender.robot.earned_rewards += ( order.maker_bond.num_satoshis ) - order.maker_bond.sender.profile.save() - order.taker_bond.sender.profile.earned_rewards += ( + order.maker_bond.sender.robot.save() + order.taker_bond.sender.robot.earned_rewards += ( order.taker_bond.num_satoshis ) - order.taker_bond.sender.profile.save() - order.trade_escrow.sender.profile.earned_rewards += ( + order.taker_bond.sender.robot.save() + order.trade_escrow.sender.robot.earned_rewards += ( order.trade_escrow.num_satoshis ) - order.trade_escrow.sender.profile.save() + order.trade_escrow.sender.robot.save() order.status = Order.Status.CCA order.save() self.message_user( @@ -315,8 +324,8 @@ class OnchainPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin): search_fields = ["address", "num_satoshis", "receiver__username", "txid"] -@admin.register(Profile) -class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin): +@admin.register(Robot) +class UserRobotAdmin(AdminChangeLinksMixin, admin.ModelAdmin): list_display = ( "avatar_tag", "id", diff --git a/api/lightning/node.py b/api/lightning/node.py index 0e36d658..a1908c43 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -39,7 +39,7 @@ except Exception: LND_GRPC_HOST = config("LND_GRPC_HOST") DISABLE_ONCHAIN = config("DISABLE_ONCHAIN", cast=bool, default=True) -MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500000) +MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500_000) class LNNode: @@ -345,7 +345,7 @@ class LNNode: ) else: max_routing_fee_sats = int( - float(num_satoshis) * float(routing_budget_ppm) / 1000000 + float(num_satoshis) * float(routing_budget_ppm) / 1_000_000 ) if route_hints: @@ -357,7 +357,7 @@ class LNNode: for hop_hint in hinted_route.hop_hints: route_cost += hop_hint.fee_base_msat / 1000 route_cost += ( - hop_hint.fee_proportional_millionths * num_satoshis / 1000000 + hop_hint.fee_proportional_millionths * num_satoshis / 1_000_000 ) # ...and store the cost of the route to the array diff --git a/api/logics.py b/api/logics.py index 85d3b261..62fef2ae 100644 --- a/api/logics.py +++ b/api/logics.py @@ -4,11 +4,12 @@ from datetime import timedelta import gnupg from decouple import config +from django.contrib.auth.models import User from django.db.models import Q, Sum from django.utils import timezone from api.lightning.node import LNNode -from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, User +from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order from api.tasks import send_notification from api.utils import validate_onchain_address from chat.models import Message @@ -266,9 +267,9 @@ class Logics: price = exchange_rate * (1 + float(premium) / 100) else: amount = order.amount if not order.has_range else order.max_amount - order_rate = float(amount) / (float(order.satoshis) / 100000000) + order_rate = float(amount) / (float(order.satoshis) / 100_000_000) premium = order_rate / exchange_rate - 1 - premium = int(premium * 10000) / 100 # 2 decimals left + premium = int(premium * 10_000) / 100 # 2 decimals left price = order_rate significant_digits = 5 @@ -352,7 +353,7 @@ class Logics: order.expiry_reason = Order.ExpiryReasons.NESCRO order.save() # Reward taker with part of the maker bond - cls.add_slashed_rewards(order.maker_bond, order.taker.profile) + cls.add_slashed_rewards(order.maker_bond, order.taker.robot) return True # If maker is buyer, settle the taker's bond order goes back to public @@ -371,7 +372,7 @@ class Logics: cls.publish_order(order) send_notification.delay(order_id=order.id, message="order_published") # Reward maker with part of the taker bond - cls.add_slashed_rewards(taker_bond, order.maker.profile) + cls.add_slashed_rewards(taker_bond, order.maker.robot) return True elif order.status == Order.Status.WFI: @@ -388,7 +389,7 @@ class Logics: order.expiry_reason = Order.ExpiryReasons.NINVOI order.save() # Reward taker with part of the maker bond - cls.add_slashed_rewards(order.maker_bond, order.taker.profile) + cls.add_slashed_rewards(order.maker_bond, order.taker.robot) return True # If maker is seller settle the taker's bond, order goes back to public @@ -402,7 +403,7 @@ class Logics: cls.publish_order(order) send_notification.delay(order_id=order.id, message="order_published") # Reward maker with part of the taker bond - cls.add_slashed_rewards(taker_bond, order.maker.profile) + cls.add_slashed_rewards(taker_bond, order.maker.robot) return True elif order.status in [Order.Status.CHA, Order.Status.FSE]: @@ -417,11 +418,11 @@ class Logics: """The taker did not lock the taker_bond. Now he has to go""" # Add a time out to the taker if order.taker: - profile = order.taker.profile - profile.penalty_expiration = timezone.now() + timedelta( + robot = order.taker.robot + robot.penalty_expiration = timezone.now() + timedelta( seconds=PENALTY_TIMEOUT ) - profile.save() + robot.save() # Make order public again order.taker = None @@ -467,14 +468,14 @@ class Logics: cls.return_escrow(order) cls.settle_bond(order.maker_bond) cls.return_bond(order.taker_bond) - cls.add_slashed_rewards(order.maker_bond, order.taker.profile) + cls.add_slashed_rewards(order.maker_bond, order.taker.robot) order.status = Order.Status.MLD elif num_messages_maker == 0: cls.return_escrow(order) cls.settle_bond(order.maker_bond) cls.return_bond(order.taker_bond) - cls.add_slashed_rewards(order.taker_bond, order.maker.profile) + cls.add_slashed_rewards(order.taker_bond, order.maker.robot) order.status = Order.Status.TLD else: return False @@ -525,15 +526,15 @@ class Logics: # User could be None if a dispute is open automatically due to weird expiration. if user is not None: - profile = user.profile - profile.num_disputes = profile.num_disputes + 1 - if profile.orders_disputes_started is None: - profile.orders_disputes_started = [str(order.id)] + robot = user.robot + robot.num_disputes = robot.num_disputes + 1 + if robot.orders_disputes_started is None: + robot.orders_disputes_started = [str(order.id)] else: - profile.orders_disputes_started = list( - profile.orders_disputes_started + robot.orders_disputes_started = list( + robot.orders_disputes_started ).append(str(order.id)) - profile.save() + robot.save() send_notification.delay(order_id=order.id, message="dispute_opened") return True, None @@ -546,9 +547,9 @@ class Logics: "bad_request": "Only orders in dispute accept dispute statements" } - if len(statement) > 50000: + if len(statement) > 50_000: return False, { - "bad_statement": "The statement and chatlogs are longer than 50000 characters" + "bad_statement": "The statement and chat logs are longer than 50,000 characters" } if len(statement) < 100: @@ -617,7 +618,7 @@ class Logics: # Compute a safer available onchain liquidity: (confirmed_utxos - reserve - pending_outgoing_txs)) # Accounts for already committed outgoing TX for previous users. confirmed = onchain_payment.balance.onchain_confirmed - reserve = 300000 # We assume a reserve of 300K Sats (3 times higher than LND's default anchor reserve) + reserve = 300_000 # We assume a reserve of 300K Sats (3 times higher than LND's default anchor reserve) pending_txs = OnchainPayment.objects.filter( status__in=[OnchainPayment.Status.VALID, OnchainPayment.Status.QUEUE] ).aggregate(Sum("num_satoshis"))["num_satoshis__sum"] @@ -668,7 +669,7 @@ class Logics: fee_sats = order.last_satoshis * fee_fraction - reward_tip = int(config("REWARD_TIP")) if user.profile.is_referred else 0 + reward_tip = int(config("REWARD_TIP")) if user.robot.is_referred else 0 context = {} # context necessary for the user to submit a LN invoice @@ -677,8 +678,8 @@ class Logics: ) # Trading fee to buyer is charged here. # context necessary for the user to submit an onchain address - MIN_SWAP_AMOUNT = config("MIN_SWAP_AMOUNT", cast=int, default=20000) - MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500000) + MIN_SWAP_AMOUNT = config("MIN_SWAP_AMOUNT", cast=int, default=20_000) + MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500_000) if context["invoice_amount"] < MIN_SWAP_AMOUNT: context["swap_allowed"] = False @@ -728,7 +729,7 @@ class Logics: fee_sats = order.last_satoshis * fee_fraction - reward_tip = int(config("REWARD_TIP")) if user.profile.is_referred else 0 + reward_tip = int(config("REWARD_TIP")) if user.robot.is_referred else 0 if cls.is_seller(order, user): escrow_amount = round( @@ -837,7 +838,7 @@ class Logics: num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"] routing_budget_sats = float(num_satoshis) * ( - float(routing_budget_ppm) / 1000000 + float(routing_budget_ppm) / 1_000_000 ) num_satoshis = int(num_satoshis - routing_budget_sats) payout = LNNode.validate_ln_invoice(invoice, num_satoshis, routing_budget_ppm) @@ -910,33 +911,33 @@ class Logics: order.save() return True - def add_profile_rating(profile, rating): - """adds a new rating to a user profile""" + def add_robot_rating(robot, rating): + """adds a new rating to a user robot""" # TODO Unsafe, does not update ratings, it adds more ratings everytime a new rating is clicked. - profile.total_ratings += 1 - latest_ratings = profile.latest_ratings + robot.total_ratings += 1 + latest_ratings = robot.latest_ratings if latest_ratings is None: - profile.latest_ratings = [rating] - profile.avg_rating = rating + robot.latest_ratings = [rating] + robot.avg_rating = rating else: latest_ratings = ast.literal_eval(latest_ratings) latest_ratings.append(rating) - profile.latest_ratings = latest_ratings - profile.avg_rating = sum(list(map(int, latest_ratings))) / len( + robot.latest_ratings = latest_ratings + robot.avg_rating = sum(list(map(int, latest_ratings))) / len( latest_ratings ) # Just an average, but it is a list of strings. Has to be converted to int. - profile.save() + robot.save() def is_penalized(user): """Checks if a user that is not participant of orders has a limit on taking or making a order""" - if user.profile.penalty_expiration: - if user.profile.penalty_expiration > timezone.now(): - time_out = (user.profile.penalty_expiration - timezone.now()).seconds + if user.robot.penalty_expiration: + if user.robot.penalty_expiration > timezone.now(): + time_out = (user.robot.penalty_expiration - timezone.now()).seconds return True, time_out return False, None @@ -1032,7 +1033,7 @@ class Logics: order.status = Order.Status.UCA order.save() # Reward taker with part of the maker bond - cls.add_slashed_rewards(order.maker_bond, order.taker.profile) + cls.add_slashed_rewards(order.maker_bond, order.taker.robot) return True, None # 4.b) When taker cancel after bond (before escrow) @@ -1051,7 +1052,7 @@ class Logics: cls.publish_order(order) send_notification.delay(order_id=order.id, message="order_published") # Reward maker with part of the taker bond - cls.add_slashed_rewards(order.taker_bond, order.maker.profile) + cls.add_slashed_rewards(order.taker_bond, order.maker.robot) return True, None # 5) When trade collateral has been posted (after escrow) @@ -1171,7 +1172,7 @@ class Logics: order.last_satoshis_time = timezone.now() bond_satoshis = int(order.last_satoshis * order.bond_size / 100) - if user.profile.wants_stealth: + if user.robot.wants_stealth: description = f"This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally. Payment reference: {order.reference}" else: description = f"RoboSats - Publishing '{str(order)}' - Maker bond - This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally." @@ -1236,11 +1237,11 @@ class Logics: order.status = Order.Status.WF2 order.save() - # Both users profiles are added one more contract // Unsafe can add more than once. - order.maker.profile.total_contracts += 1 - order.taker.profile.total_contracts += 1 - order.maker.profile.save() - order.taker.profile.save() + # Both users robots are added one more contract // Unsafe can add more than once. + order.maker.robot.total_contracts += 1 + order.taker.robot.total_contracts += 1 + order.maker.robot.save() + order.taker.robot.save() # Log a market tick try: @@ -1284,7 +1285,7 @@ class Logics: order.last_satoshis_time = timezone.now() bond_satoshis = int(order.last_satoshis * order.bond_size / 100) pos_text = "Buying" if cls.is_buyer(order, user) else "Selling" - if user.profile.wants_stealth: + if user.robot.wants_stealth: description = f"This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally. Payment reference: {order.reference}" else: description = ( @@ -1381,7 +1382,7 @@ class Logics: escrow_satoshis = cls.escrow_amount(order, user)[1][ "escrow_amount" ] # Amount was fixed when taker bond was locked, fee applied here - if user.profile.wants_stealth: + if user.robot.wants_stealth: description = f"This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally. Payment reference: {order.reference}" else: description = f"RoboSats - Escrow amount for '{str(order)}' - It WILL FREEZE IN YOUR WALLET. It will be released to the buyer once you confirm you received the fiat. It will automatically return if buyer does not confirm the payment." @@ -1645,12 +1646,12 @@ class Logics: if order.status in rating_allowed_status: # if maker, rates taker if order.maker == user and order.maker_rated is False: - cls.add_profile_rating(order.taker.profile, rating) + cls.add_robot_rating(order.taker.robot, rating) order.maker_rated = True order.save() # if taker, rates maker if order.taker == user and order.taker_rated is False: - cls.add_profile_rating(order.maker.profile, rating) + cls.add_robot_rating(order.maker.robot, rating) order.taker_rated = True order.save() else: @@ -1660,8 +1661,8 @@ class Logics: @classmethod def rate_platform(cls, user, rating): - user.profile.platform_rating = rating - user.profile.save() + user.robot.platform_rating = rating + user.robot.save() return True, None @classmethod @@ -1671,27 +1672,27 @@ class Logics: If participants of the order were referred, the reward is given to the referees. """ - if order.maker.profile.is_referred: - profile = order.maker.profile.referred_by - profile.pending_rewards += int(config("REWARD_TIP")) - profile.save() + if order.maker.robot.is_referred: + robot = order.maker.robot.referred_by + robot.pending_rewards += int(config("REWARD_TIP")) + robot.save() - if order.taker.profile.is_referred: - profile = order.taker.profile.referred_by - profile.pending_rewards += int(config("REWARD_TIP")) - profile.save() + if order.taker.robot.is_referred: + robot = order.taker.robot.referred_by + robot.pending_rewards += int(config("REWARD_TIP")) + robot.save() return @classmethod - def add_slashed_rewards(cls, bond, profile): + def add_slashed_rewards(cls, bond, robot): """ When a bond is slashed due to overtime, rewards the user that was waiting. """ reward_fraction = float(config("SLASHED_BOND_REWARD_SPLIT")) reward = int(bond.num_satoshis * reward_fraction) - profile.earned_rewards += reward - profile.save() + robot.earned_rewards += reward + robot.save() return @@ -1700,10 +1701,10 @@ class Logics: # only a user with positive withdraw balance can use this - if user.profile.earned_rewards < 1: + if user.robot.earned_rewards < 1: return False, {"bad_invoice": "You have not earned rewards"} - num_satoshis = user.profile.earned_rewards + num_satoshis = user.robot.earned_rewards routing_budget_sats = int( max( @@ -1712,7 +1713,7 @@ class Logics: ) ) # 1000 ppm or 10 sats - routing_budget_ppm = (routing_budget_sats / float(num_satoshis)) * 1000000 + routing_budget_ppm = (routing_budget_sats / float(num_satoshis)) * 1_000_000 reward_payout = LNNode.validate_ln_invoice( invoice, num_satoshis, routing_budget_ppm ) @@ -1738,21 +1739,21 @@ class Logics: except Exception: return False, {"bad_invoice": "Give me a new invoice"} - user.profile.earned_rewards = 0 - user.profile.save() + user.robot.earned_rewards = 0 + user.robot.save() # Pays the invoice. paid, failure_reason = LNNode.pay_invoice(lnpayment) if paid: - user.profile.earned_rewards = 0 - user.profile.claimed_rewards += num_satoshis - user.profile.save() + user.robot.earned_rewards = 0 + user.robot.claimed_rewards += num_satoshis + user.robot.save() return True, None # If fails, adds the rewards again. else: - user.profile.earned_rewards = num_satoshis - user.profile.save() + user.robot.earned_rewards = num_satoshis + user.robot.save() context = {} context["bad_invoice"] = failure_reason return False, context @@ -1823,7 +1824,7 @@ class Logics: platform_summary = {} platform_summary["contract_exchange_rate"] = float(order.amount) / ( - float(order.last_satoshis) / 100000000 + float(order.last_satoshis) / 100_000_000 ) if order.last_satoshis_time is not None: platform_summary["contract_timestamp"] = order.last_satoshis_time diff --git a/api/management/commands/telegram_watcher.py b/api/management/commands/telegram_watcher.py index 0905999d..c937c4eb 100644 --- a/api/management/commands/telegram_watcher.py +++ b/api/management/commands/telegram_watcher.py @@ -5,7 +5,7 @@ from decouple import config from django.core.management.base import BaseCommand from django.db import transaction -from api.models import Profile +from api.models import Robot from api.notifications import Telegram from api.utils import get_session @@ -52,12 +52,12 @@ class Command(BaseCommand): if len(parts) < 2: self.telegram.send_message( chat_id=result["message"]["from"]["id"], - text='You must enable the notifications bot using the RoboSats client. Click on your "Robot profile" -> "Enable Telegram" and follow the link or scan the QR code.', + text='You must enable the notifications bot using the RoboSats client. Click on your "Robot robot" -> "Enable Telegram" and follow the link or scan the QR code.', ) continue token = parts[-1] - profile = Profile.objects.filter(telegram_token=token).first() - if not profile: + robot = Robot.objects.filter(telegram_token=token).first() + if not robot: self.telegram.send_message( chat_id=result["message"]["from"]["id"], text=f'Wops, invalid token! There is no Robot with telegram chat token "{token}"', @@ -68,13 +68,13 @@ class Command(BaseCommand): while attempts >= 0: try: with transaction.atomic(): - profile.telegram_chat_id = result["message"]["from"]["id"] - profile.telegram_lang_code = result["message"]["from"][ + robot.telegram_chat_id = result["message"]["from"]["id"] + robot.telegram_lang_code = result["message"]["from"][ "language_code" ] - self.telegram.welcome(profile.user) - profile.telegram_enabled = True - profile.save() + self.telegram.welcome(robot.user) + robot.telegram_enabled = True + robot.save() break except Exception: time.sleep(5) diff --git a/api/models.py b/api/models.py deleted file mode 100644 index 50ed0d95..00000000 --- a/api/models.py +++ /dev/null @@ -1,758 +0,0 @@ -import json -import uuid -from pathlib import Path - -from decouple import config -from django.conf import settings -from django.contrib.auth.models import User -from django.core.validators import ( - MaxValueValidator, - MinValueValidator, - validate_comma_separated_integer_list, -) -from django.db import models -from django.db.models.signals import post_save, pre_delete -from django.dispatch import receiver -from django.template.defaultfilters import truncatechars -from django.utils import timezone -from django.utils.html import mark_safe - -from control.models import BalanceLog - -MIN_TRADE = int(config("MIN_TRADE")) -MAX_TRADE = int(config("MAX_TRADE")) -MIN_SWAP_AMOUNT = int(config("MIN_SWAP_AMOUNT")) -FEE = float(config("FEE")) -DEFAULT_BOND_SIZE = float(config("DEFAULT_BOND_SIZE")) - - -class Currency(models.Model): - - currency_dict = json.load(open("frontend/static/assets/currencies.json")) - currency_choices = [(int(val), label) for val, label in list(currency_dict.items())] - - currency = models.PositiveSmallIntegerField( - choices=currency_choices, null=False, unique=True - ) - exchange_rate = models.DecimalField( - max_digits=18, - decimal_places=4, - default=None, - null=True, - validators=[MinValueValidator(0)], - ) - timestamp = models.DateTimeField(default=timezone.now) - - def __str__(self): - # returns currency label ( 3 letters code) - return self.currency_dict[str(self.currency)] - - class Meta: - verbose_name = "Cached market currency" - verbose_name_plural = "Currencies" - - -class LNPayment(models.Model): - class Types(models.IntegerChoices): - NORM = 0, "Regular invoice" - HOLD = 1, "hold invoice" - - class Concepts(models.IntegerChoices): - MAKEBOND = 0, "Maker bond" - TAKEBOND = 1, "Taker bond" - TRESCROW = 2, "Trade escrow" - PAYBUYER = 3, "Payment to buyer" - WITHREWA = 4, "Withdraw rewards" - - class Status(models.IntegerChoices): - INVGEN = 0, "Generated" - LOCKED = 1, "Locked" - SETLED = 2, "Settled" - RETNED = 3, "Returned" - CANCEL = 4, "Cancelled" - EXPIRE = 5, "Expired" - VALIDI = 6, "Valid" - FLIGHT = 7, "In flight" - SUCCED = 8, "Succeeded" - FAILRO = 9, "Routing failed" - - class FailureReason(models.IntegerChoices): - NOTYETF = 0, "Payment isn't failed (yet)" - TIMEOUT = ( - 1, - "There are more routes to try, but the payment timeout was exceeded.", - ) - NOROUTE = ( - 2, - "All possible routes were tried and failed permanently. Or there were no routes to the destination at all.", - ) - NONRECO = 3, "A non-recoverable error has occurred." - INCORRE = ( - 4, - "Payment details are incorrect (unknown hash, invalid amount or invalid final CLTV delta).", - ) - NOBALAN = 5, "Insufficient unlocked balance in RoboSats' node." - - # payment use details - type = models.PositiveSmallIntegerField( - choices=Types.choices, null=False, default=Types.HOLD - ) - concept = models.PositiveSmallIntegerField( - choices=Concepts.choices, null=False, default=Concepts.MAKEBOND - ) - status = models.PositiveSmallIntegerField( - choices=Status.choices, null=False, default=Status.INVGEN - ) - failure_reason = models.PositiveSmallIntegerField( - choices=FailureReason.choices, null=True, default=None - ) - - # payment info - payment_hash = models.CharField( - max_length=100, unique=True, default=None, blank=True, primary_key=True - ) - invoice = models.CharField( - max_length=1200, unique=True, null=True, default=None, blank=True - ) # Some invoices with lots of routing hints might be long - preimage = models.CharField( - max_length=64, unique=True, null=True, default=None, blank=True - ) - description = models.CharField( - max_length=500, unique=False, null=True, default=None, blank=True - ) - num_satoshis = models.PositiveBigIntegerField( - validators=[ - MinValueValidator(100), - MaxValueValidator(1.5 * MAX_TRADE), - ] - ) - # Routing budget in PPM - routing_budget_ppm = models.PositiveBigIntegerField( - default=0, - null=False, - validators=[ - MinValueValidator(0), - MaxValueValidator(100000), - ], - ) - # Routing budget in Sats. Only for reporting summaries. - routing_budget_sats = models.DecimalField( - max_digits=10, decimal_places=3, default=0, null=False, blank=False - ) - # Fee in sats with mSats decimals fee_msat - fee = models.DecimalField( - max_digits=10, decimal_places=3, default=0, null=False, blank=False - ) - created_at = models.DateTimeField() - expires_at = models.DateTimeField() - cltv_expiry = models.PositiveSmallIntegerField(null=True, default=None, blank=True) - expiry_height = models.PositiveBigIntegerField(null=True, default=None, blank=True) - - # routing - routing_attempts = models.PositiveSmallIntegerField(null=False, default=0) - last_routing_time = models.DateTimeField(null=True, default=None, blank=True) - in_flight = models.BooleanField(default=False, null=False, blank=False) - # involved parties - sender = models.ForeignKey( - User, related_name="sender", on_delete=models.SET_NULL, null=True, default=None - ) - receiver = models.ForeignKey( - User, - related_name="receiver", - on_delete=models.SET_NULL, - null=True, - default=None, - ) - - def __str__(self): - return f"LN-{str(self.payment_hash)[:8]}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}" - - class Meta: - verbose_name = "Lightning payment" - verbose_name_plural = "Lightning payments" - - @property - def hash(self): - # Payment hash is the primary key of LNpayments - # However it is too long for the admin panel. - # We created a truncated property for display 'hash' - return truncatechars(self.payment_hash, 10) - - -class OnchainPayment(models.Model): - class Concepts(models.IntegerChoices): - PAYBUYER = 3, "Payment to buyer" - - class Status(models.IntegerChoices): - CREAT = 0, "Created" # User was given platform fees and suggested mining fees - VALID = 1, "Valid" # Valid onchain address and fee submitted - MEMPO = 2, "In mempool" # Tx is sent to mempool - CONFI = 3, "Confirmed" # Tx is confirmed +2 blocks - CANCE = 4, "Cancelled" # Cancelled tx - QUEUE = 5, "Queued" # Payment is queued to be sent out - - def get_balance(): - balance = BalanceLog.objects.create() - return balance.time - - # payment use details - concept = models.PositiveSmallIntegerField( - choices=Concepts.choices, null=False, default=Concepts.PAYBUYER - ) - status = models.PositiveSmallIntegerField( - choices=Status.choices, null=False, default=Status.CREAT - ) - - broadcasted = models.BooleanField(default=False, null=False, blank=False) - - # payment info - address = models.CharField( - max_length=100, unique=False, default=None, null=True, blank=True - ) - - txid = models.CharField( - max_length=64, unique=True, null=True, default=None, blank=True - ) - - num_satoshis = models.PositiveBigIntegerField( - null=True, - validators=[ - MinValueValidator(0.5 * MIN_SWAP_AMOUNT), - MaxValueValidator(1.5 * MAX_TRADE), - ], - ) - sent_satoshis = models.PositiveBigIntegerField( - null=True, - validators=[ - MinValueValidator(0.5 * MIN_SWAP_AMOUNT), - MaxValueValidator(1.5 * MAX_TRADE), - ], - ) - # fee in sats/vbyte with mSats decimals fee_msat - suggested_mining_fee_rate = models.DecimalField( - max_digits=6, - decimal_places=3, - default=2.05, - null=False, - blank=False, - validators=[MinValueValidator(1), MaxValueValidator(999)], - ) - mining_fee_rate = models.DecimalField( - max_digits=6, - decimal_places=3, - default=2.05, - null=False, - blank=False, - validators=[MinValueValidator(1), MaxValueValidator(999)], - ) - mining_fee_sats = models.PositiveBigIntegerField(default=0, null=False, blank=False) - - # platform onchain/channels balance at creation, swap fee rate as percent of total volume - balance = models.ForeignKey( - BalanceLog, - related_name="balance", - on_delete=models.SET_NULL, - null=True, - default=get_balance, - ) - - swap_fee_rate = models.DecimalField( - max_digits=4, - decimal_places=2, - default=float(config("MIN_SWAP_FEE")) * 100, - null=False, - blank=False, - ) - - created_at = models.DateTimeField(default=timezone.now) - - # involved parties - receiver = models.ForeignKey( - User, - related_name="tx_receiver", - on_delete=models.SET_NULL, - null=True, - default=None, - ) - - def __str__(self): - return f"TX-{str(self.id)}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}" - - class Meta: - verbose_name = "Onchain payment" - verbose_name_plural = "Onchain payments" - - @property - def hash(self): - # Display txid as 'hash' truncated - return truncatechars(self.txid, 10) - - -class Order(models.Model): - class Types(models.IntegerChoices): - BUY = 0, "BUY" - SELL = 1, "SELL" - - class Status(models.IntegerChoices): - WFB = 0, "Waiting for maker bond" - PUB = 1, "Public" - PAU = 2, "Paused" - TAK = 3, "Waiting for taker bond" - UCA = 4, "Cancelled" - EXP = 5, "Expired" - WF2 = 6, "Waiting for trade collateral and buyer invoice" - WFE = 7, "Waiting only for seller trade collateral" - WFI = 8, "Waiting only for buyer invoice" - CHA = 9, "Sending fiat - In chatroom" - FSE = 10, "Fiat sent - In chatroom" - DIS = 11, "In dispute" - CCA = 12, "Collaboratively cancelled" - PAY = 13, "Sending satoshis to buyer" - SUC = 14, "Sucessful trade" - FAI = 15, "Failed lightning network routing" - WFR = 16, "Wait for dispute resolution" - MLD = 17, "Maker lost dispute" - TLD = 18, "Taker lost dispute" - - class ExpiryReasons(models.IntegerChoices): - NTAKEN = 0, "Expired not taken" - NMBOND = 1, "Maker bond not locked" - NESCRO = 2, "Escrow not locked" - NINVOI = 3, "Invoice not submitted" - NESINV = 4, "Neither escrow locked or invoice submitted" - - # order info - reference = models.UUIDField(default=uuid.uuid4, editable=False) - status = models.PositiveSmallIntegerField( - choices=Status.choices, null=False, default=Status.WFB - ) - created_at = models.DateTimeField(default=timezone.now) - expires_at = models.DateTimeField() - expiry_reason = models.PositiveSmallIntegerField( - choices=ExpiryReasons.choices, null=True, blank=True, default=None - ) - - # order details - type = models.PositiveSmallIntegerField(choices=Types.choices, null=False) - currency = models.ForeignKey(Currency, null=True, on_delete=models.SET_NULL) - amount = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True) - has_range = models.BooleanField(default=False, null=False, blank=False) - min_amount = models.DecimalField( - max_digits=18, decimal_places=8, null=True, blank=True - ) - max_amount = models.DecimalField( - max_digits=18, decimal_places=8, null=True, blank=True - ) - payment_method = models.CharField( - max_length=70, null=False, default="not specified", blank=True - ) - # order pricing method. A explicit amount of sats, or a relative premium above/below market. - is_explicit = models.BooleanField(default=False, null=False) - # marked to market - premium = models.DecimalField( - max_digits=5, - decimal_places=2, - default=0, - null=True, - validators=[MinValueValidator(-100), MaxValueValidator(999)], - blank=True, - ) - # explicit - satoshis = models.PositiveBigIntegerField( - null=True, - validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], - blank=True, - ) - # optionally makers can choose the public order duration length (seconds) - public_duration = models.PositiveBigIntegerField( - default=60 * 60 * int(config("DEFAULT_PUBLIC_ORDER_DURATION")) - 1, - null=False, - validators=[ - MinValueValidator( - 60 * 60 * float(config("MIN_PUBLIC_ORDER_DURATION")) - ), # Min is 10 minutes - MaxValueValidator( - 60 * 60 * float(config("MAX_PUBLIC_ORDER_DURATION")) - ), # Max is 24 Hours - ], - blank=False, - ) - - # optionally makers can choose the escrow lock / invoice submission step length (seconds) - escrow_duration = models.PositiveBigIntegerField( - default=60 * int(config("INVOICE_AND_ESCROW_DURATION")) - 1, - null=False, - validators=[ - MinValueValidator(60 * 30), # Min is 30 minutes - MaxValueValidator(60 * 60 * 8), # Max is 8 Hours - ], - blank=False, - ) - - # optionally makers can choose the fidelity bond size of the maker and taker (%) - bond_size = models.DecimalField( - max_digits=4, - decimal_places=2, - default=DEFAULT_BOND_SIZE, - null=False, - validators=[ - MinValueValidator(float(config("MIN_BOND_SIZE"))), # 1 % - MaxValueValidator(float(config("MAX_BOND_SIZE"))), # 15 % - ], - blank=False, - ) - - # how many sats at creation and at last check (relevant for marked to market) - t0_satoshis = models.PositiveBigIntegerField( - null=True, - validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], - blank=True, - ) # sats at creation - last_satoshis = models.PositiveBigIntegerField( - null=True, - validators=[MinValueValidator(0), MaxValueValidator(MAX_TRADE * 2)], - blank=True, - ) # sats last time checked. Weird if 2* trade max... - # timestamp of last_satoshis - last_satoshis_time = models.DateTimeField(null=True, default=None, blank=True) - # time the fiat exchange is confirmed and Sats released to buyer - contract_finalization_time = models.DateTimeField( - null=True, default=None, blank=True - ) - # order participants - maker = models.ForeignKey( - User, related_name="maker", on_delete=models.SET_NULL, null=True, default=None - ) # unique = True, a maker can only make one order - taker = models.ForeignKey( - User, - related_name="taker", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) # unique = True, a taker can only take one order - maker_last_seen = models.DateTimeField(null=True, default=None, blank=True) - taker_last_seen = models.DateTimeField(null=True, default=None, blank=True) - - # When collaborative cancel is needed and one partner has cancelled. - maker_asked_cancel = models.BooleanField(default=False, null=False) - taker_asked_cancel = models.BooleanField(default=False, null=False) - - is_fiat_sent = models.BooleanField(default=False, null=False) - reverted_fiat_sent = models.BooleanField(default=False, null=False) - - # in dispute - is_disputed = models.BooleanField(default=False, null=False) - maker_statement = models.TextField( - max_length=50000, null=True, default=None, blank=True - ) - taker_statement = models.TextField( - max_length=50000, null=True, default=None, blank=True - ) - - # LNpayments - # Order collateral - maker_bond = models.OneToOneField( - LNPayment, - related_name="order_made", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) - taker_bond = models.OneToOneField( - LNPayment, - related_name="order_taken", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) - trade_escrow = models.OneToOneField( - LNPayment, - related_name="order_escrow", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) - # is buyer payout a LN invoice (false) or on chain address (true) - is_swap = models.BooleanField(default=False, null=False) - # buyer payment LN invoice - payout = models.OneToOneField( - LNPayment, - related_name="order_paid_LN", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) - # buyer payment address - payout_tx = models.OneToOneField( - OnchainPayment, - related_name="order_paid_TX", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) - - # ratings - maker_rated = models.BooleanField(default=False, null=False) - taker_rated = models.BooleanField(default=False, null=False) - maker_platform_rated = models.BooleanField(default=False, null=False) - taker_platform_rated = models.BooleanField(default=False, null=False) - - def __str__(self): - if self.has_range and self.amount is None: - amt = str(float(self.min_amount)) + "-" + str(float(self.max_amount)) - else: - amt = float(self.amount) - return f"Order {self.id}: {self.Types(self.type).label} BTC for {amt} {self.currency}" - - def t_to_expire(self, status): - - t_to_expire = { - 0: int(config("EXP_MAKER_BOND_INVOICE")), # 'Waiting for maker bond' - 1: self.public_duration, # 'Public' - 2: 0, # 'Deleted' - 3: int(config("EXP_TAKER_BOND_INVOICE")), # 'Waiting for taker bond' - 4: 0, # 'Cancelled' - 5: 0, # 'Expired' - 6: int( - self.escrow_duration - ), # 'Waiting for trade collateral and buyer invoice' - 7: int(self.escrow_duration), # 'Waiting only for seller trade collateral' - 8: int(self.escrow_duration), # 'Waiting only for buyer invoice' - 9: 60 - * 60 - * int(config("FIAT_EXCHANGE_DURATION")), # 'Sending fiat - In chatroom' - 10: 60 - * 60 - * int(config("FIAT_EXCHANGE_DURATION")), # 'Fiat sent - In chatroom' - 11: 1 * 24 * 60 * 60, # 'In dispute' - 12: 0, # 'Collaboratively cancelled' - 13: 100 * 24 * 60 * 60, # 'Sending satoshis to buyer' - 14: 100 * 24 * 60 * 60, # 'Sucessful trade' - 15: 100 * 24 * 60 * 60, # 'Failed lightning network routing' - 16: 100 * 24 * 60 * 60, # 'Wait for dispute resolution' - 17: 100 * 24 * 60 * 60, # 'Maker lost dispute' - 18: 100 * 24 * 60 * 60, # 'Taker lost dispute' - } - - return t_to_expire[status] - - -@receiver(pre_delete, sender=Order) -def delete_lnpayment_at_order_deletion(sender, instance, **kwargs): - to_delete = ( - instance.maker_bond, - instance.payout, - instance.taker_bond, - instance.trade_escrow, - ) - - for lnpayment in to_delete: - try: - lnpayment.delete() - except Exception: - pass - - -class Profile(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE) - - # PGP keys, used for E2E chat encryption. Priv key is encrypted with user's passphrase (highEntropyToken) - public_key = models.TextField( - # Actualy only 400-500 characters for ECC, but other types might be longer - max_length=2000, - null=True, - default=None, - blank=True, - ) - encrypted_private_key = models.TextField( - max_length=2000, - null=True, - default=None, - blank=True, - ) - - # Total trades - total_contracts = models.PositiveIntegerField(null=False, default=0) - - # Ratings stored as a comma separated integer list - total_ratings = models.PositiveIntegerField(null=False, default=0) - latest_ratings = models.CharField( - max_length=999, - null=True, - default=None, - validators=[validate_comma_separated_integer_list], - blank=True, - ) # Will only store latest rating - avg_rating = models.DecimalField( - max_digits=4, - decimal_places=1, - default=None, - null=True, - validators=[MinValueValidator(0), MaxValueValidator(100)], - blank=True, - ) - # Used to deep link telegram chat in case telegram notifications are enabled - telegram_token = models.CharField(max_length=20, null=True, blank=True) - telegram_chat_id = models.BigIntegerField(null=True, default=None, blank=True) - telegram_enabled = models.BooleanField(default=False, null=False) - telegram_lang_code = models.CharField(max_length=10, null=True, blank=True) - telegram_welcomed = models.BooleanField(default=False, null=False) - - # Referral program - is_referred = models.BooleanField(default=False, null=False) - referred_by = models.ForeignKey( - "self", - related_name="referee", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) - referral_code = models.CharField(max_length=15, null=True, blank=True) - # Recent rewards from referred trades that will be "earned" at a later point to difficult spionage. - pending_rewards = models.PositiveIntegerField(null=False, default=0) - # Claimable rewards - earned_rewards = models.PositiveIntegerField(null=False, default=0) - # Total claimed rewards - claimed_rewards = models.PositiveIntegerField(null=False, default=0) - - # Disputes - num_disputes = models.PositiveIntegerField(null=False, default=0) - lost_disputes = models.PositiveIntegerField(null=False, default=0) - num_disputes_started = models.PositiveIntegerField(null=False, default=0) - orders_disputes_started = models.CharField( - max_length=999, - null=True, - default=None, - validators=[validate_comma_separated_integer_list], - blank=True, - ) # Will only store ID of orders - - # RoboHash - avatar = models.ImageField( - default=("static/assets/avatars/" + "unknown_avatar.png"), - verbose_name="Avatar", - blank=True, - ) - - # Penalty expiration (only used then taking/cancelling repeatedly orders in the book before comitting bond) - penalty_expiration = models.DateTimeField(null=True, default=None, blank=True) - - # Platform rate - platform_rating = models.PositiveIntegerField(null=True, default=None, blank=True) - - # Stealth invoices - wants_stealth = models.BooleanField(default=True, null=False) - - @receiver(post_save, sender=User) - def create_user_profile(sender, instance, created, **kwargs): - if created: - Profile.objects.create(user=instance) - - @receiver(post_save, sender=User) - def save_user_profile(sender, instance, **kwargs): - instance.profile.save() - - @receiver(pre_delete, sender=User) - def del_avatar_from_disk(sender, instance, **kwargs): - try: - avatar_file = Path( - settings.AVATAR_ROOT + instance.profile.avatar.url.split("/")[-1] - ) - avatar_file.unlink() - except Exception: - pass - - def __str__(self): - return self.user.username - - # to display avatars in admin panel - def get_avatar(self): - if not self.avatar: - return settings.STATIC_ROOT + "unknown_avatar.png" - return self.avatar.url - - # method to create a fake table field in read only mode - def avatar_tag(self): - return mark_safe('' % self.get_avatar()) - - -class MarketTick(models.Model): - """ - Records tick by tick Non-KYC Bitcoin price. - Data to be aggregated and offered via public API. - - It is checked against current CEX price for useful - insight on the historical premium of Non-KYC BTC - - Price is set when taker bond is locked. Both - maker and taker are commited with bonds (contract - is finished and cancellation has a cost) - """ - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - price = models.DecimalField( - max_digits=16, - decimal_places=2, - default=None, - null=True, - validators=[MinValueValidator(0)], - ) - volume = models.DecimalField( - max_digits=8, - decimal_places=8, - default=None, - null=True, - validators=[MinValueValidator(0)], - ) - premium = models.DecimalField( - max_digits=5, - decimal_places=2, - default=None, - null=True, - validators=[MinValueValidator(-100), MaxValueValidator(999)], - blank=True, - ) - currency = models.ForeignKey(Currency, null=True, on_delete=models.SET_NULL) - timestamp = models.DateTimeField(default=timezone.now) - - # Relevant to keep record of the historical fee, so the insight on the premium can be better analyzed - fee = models.DecimalField( - max_digits=4, - decimal_places=4, - default=FEE, - validators=[MinValueValidator(0), MaxValueValidator(1)], - ) - - def log_a_tick(order): - """ - Creates a new tick - """ - - if not order.taker_bond: - return None - - elif order.taker_bond.status == LNPayment.Status.LOCKED: - volume = order.last_satoshis / 100000000 - price = float(order.amount) / volume # Amount Fiat / Amount BTC - market_exchange_rate = float(order.currency.exchange_rate) - premium = 100 * (price / market_exchange_rate - 1) - - tick = MarketTick.objects.create( - price=price, volume=volume, premium=premium, currency=order.currency - ) - - tick.save() - - def __str__(self): - return f"Tick: {str(self.id)[:8]}" - - class Meta: - verbose_name = "Market tick" - verbose_name_plural = "Market ticks" diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 00000000..d29ac0e4 --- /dev/null +++ b/api/models/__init__.py @@ -0,0 +1,8 @@ +from .currency import Currency +from .ln_payment import LNPayment +from .market_tick import MarketTick +from .onchain_payment import OnchainPayment +from .order import Order +from .robot import Robot + +__all__ = ["Currency", "LNPayment", "MarketTick", "OnchainPayment", "Order", "Robot"] diff --git a/api/models/currency.py b/api/models/currency.py new file mode 100644 index 00000000..a6a3ffef --- /dev/null +++ b/api/models/currency.py @@ -0,0 +1,31 @@ +import json + +from django.core.validators import MinValueValidator +from django.db import models +from django.utils import timezone + + +class Currency(models.Model): + + currency_dict = json.load(open("frontend/static/assets/currencies.json")) + currency_choices = [(int(val), label) for val, label in list(currency_dict.items())] + + currency = models.PositiveSmallIntegerField( + choices=currency_choices, null=False, unique=True + ) + exchange_rate = models.DecimalField( + max_digits=18, + decimal_places=4, + default=None, + null=True, + validators=[MinValueValidator(0)], + ) + timestamp = models.DateTimeField(default=timezone.now) + + def __str__(self): + # returns currency label ( 3 letters code) + return self.currency_dict[str(self.currency)] + + class Meta: + verbose_name = "Cached market currency" + verbose_name_plural = "Currencies" diff --git a/api/models/ln_payment.py b/api/models/ln_payment.py new file mode 100644 index 00000000..4d13feb6 --- /dev/null +++ b/api/models/ln_payment.py @@ -0,0 +1,132 @@ +from decouple import config +from django.contrib.auth.models import User +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.template.defaultfilters import truncatechars + + +class LNPayment(models.Model): + class Types(models.IntegerChoices): + NORM = 0, "Regular invoice" + HOLD = 1, "hold invoice" + + class Concepts(models.IntegerChoices): + MAKEBOND = 0, "Maker bond" + TAKEBOND = 1, "Taker bond" + TRESCROW = 2, "Trade escrow" + PAYBUYER = 3, "Payment to buyer" + WITHREWA = 4, "Withdraw rewards" + + class Status(models.IntegerChoices): + INVGEN = 0, "Generated" + LOCKED = 1, "Locked" + SETLED = 2, "Settled" + RETNED = 3, "Returned" + CANCEL = 4, "Cancelled" + EXPIRE = 5, "Expired" + VALIDI = 6, "Valid" + FLIGHT = 7, "In flight" + SUCCED = 8, "Succeeded" + FAILRO = 9, "Routing failed" + + class FailureReason(models.IntegerChoices): + NOTYETF = 0, "Payment isn't failed (yet)" + TIMEOUT = ( + 1, + "There are more routes to try, but the payment timeout was exceeded.", + ) + NOROUTE = ( + 2, + "All possible routes were tried and failed permanently. Or there were no routes to the destination at all.", + ) + NONRECO = 3, "A non-recoverable error has occurred." + INCORRE = ( + 4, + "Payment details are incorrect (unknown hash, invalid amount or invalid final CLTV delta).", + ) + NOBALAN = 5, "Insufficient unlocked balance in RoboSats' node." + + # payment use details + type = models.PositiveSmallIntegerField( + choices=Types.choices, null=False, default=Types.HOLD + ) + concept = models.PositiveSmallIntegerField( + choices=Concepts.choices, null=False, default=Concepts.MAKEBOND + ) + status = models.PositiveSmallIntegerField( + choices=Status.choices, null=False, default=Status.INVGEN + ) + failure_reason = models.PositiveSmallIntegerField( + choices=FailureReason.choices, null=True, default=None + ) + + # payment info + payment_hash = models.CharField( + max_length=100, unique=True, default=None, blank=True, primary_key=True + ) + invoice = models.CharField( + max_length=1200, unique=True, null=True, default=None, blank=True + ) # Some invoices with lots of routing hints might be long + preimage = models.CharField( + max_length=64, unique=True, null=True, default=None, blank=True + ) + description = models.CharField( + max_length=500, unique=False, null=True, default=None, blank=True + ) + num_satoshis = models.PositiveBigIntegerField( + validators=[ + MinValueValidator(100), + MaxValueValidator(1.5 * config("MAX_TRADE", cast=int, default=1_000_000)), + ] + ) + # Routing budget in PPM + routing_budget_ppm = models.PositiveBigIntegerField( + default=0, + null=False, + validators=[ + MinValueValidator(0), + MaxValueValidator(100_000), + ], + ) + # Routing budget in Sats. Only for reporting summaries. + routing_budget_sats = models.DecimalField( + max_digits=10, decimal_places=3, default=0, null=False, blank=False + ) + # Fee in sats with mSats decimals fee_msat + fee = models.DecimalField( + max_digits=10, decimal_places=3, default=0, null=False, blank=False + ) + created_at = models.DateTimeField() + expires_at = models.DateTimeField() + cltv_expiry = models.PositiveSmallIntegerField(null=True, default=None, blank=True) + expiry_height = models.PositiveBigIntegerField(null=True, default=None, blank=True) + + # routing + routing_attempts = models.PositiveSmallIntegerField(null=False, default=0) + last_routing_time = models.DateTimeField(null=True, default=None, blank=True) + in_flight = models.BooleanField(default=False, null=False, blank=False) + # involved parties + sender = models.ForeignKey( + User, related_name="sender", on_delete=models.SET_NULL, null=True, default=None + ) + receiver = models.ForeignKey( + User, + related_name="receiver", + on_delete=models.SET_NULL, + null=True, + default=None, + ) + + def __str__(self): + return f"LN-{str(self.payment_hash)[:8]}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}" + + class Meta: + verbose_name = "Lightning payment" + verbose_name_plural = "Lightning payments" + + @property + def hash(self): + # Payment hash is the primary key of LNpayments + # However it is too long for the admin panel. + # We created a truncated property for display 'hash' + return truncatechars(self.payment_hash, 10) diff --git a/api/models/market_tick.py b/api/models/market_tick.py new file mode 100644 index 00000000..73352b5d --- /dev/null +++ b/api/models/market_tick.py @@ -0,0 +1,84 @@ +import uuid + +from decouple import config +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils import timezone + +FEE = float(config("FEE")) + + +class MarketTick(models.Model): + """ + Records tick by tick Non-KYC Bitcoin price. + Data to be aggregated and offered via public API. + + It is checked against current CEX price for useful + insight on the historical premium of Non-KYC BTC + + Price is set when taker bond is locked. Both + maker and taker are committed with bonds (contract + is finished and cancellation has a cost) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + price = models.DecimalField( + max_digits=16, + decimal_places=2, + default=None, + null=True, + validators=[MinValueValidator(0)], + ) + volume = models.DecimalField( + max_digits=8, + decimal_places=8, + default=None, + null=True, + validators=[MinValueValidator(0)], + ) + premium = models.DecimalField( + max_digits=5, + decimal_places=2, + default=None, + null=True, + validators=[MinValueValidator(-100), MaxValueValidator(999)], + blank=True, + ) + currency = models.ForeignKey("api.Currency", null=True, on_delete=models.SET_NULL) + timestamp = models.DateTimeField(default=timezone.now) + + # Relevant to keep record of the historical fee, so the insight on the premium can be better analyzed + fee = models.DecimalField( + max_digits=4, + decimal_places=4, + default=config("FEE", cast=float, default=0), + validators=[MinValueValidator(0), MaxValueValidator(1)], + ) + + def log_a_tick(order): + """ + Creates a new tick + """ + from api.models import LNPayment + + if not order.taker_bond: + return None + + elif order.taker_bond.status == LNPayment.Status.LOCKED: + volume = order.last_satoshis / 100_000_000 + price = float(order.amount) / volume # Amount Fiat / Amount BTC + market_exchange_rate = float(order.currency.exchange_rate) + premium = 100 * (price / market_exchange_rate - 1) + + tick = MarketTick.objects.create( + price=price, volume=volume, premium=premium, currency=order.currency + ) + + tick.save() + + def __str__(self): + return f"Tick: {str(self.id)[:8]}" + + class Meta: + verbose_name = "Market tick" + verbose_name_plural = "Market ticks" diff --git a/api/models/onchain_payment.py b/api/models/onchain_payment.py new file mode 100644 index 00000000..f3efc947 --- /dev/null +++ b/api/models/onchain_payment.py @@ -0,0 +1,120 @@ +from decouple import config +from django.contrib.auth.models import User +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.template.defaultfilters import truncatechars +from django.utils import timezone + +from control.models import BalanceLog + +MAX_TRADE = config("MAX_TRADE", cast=int, default=1_000_000) +MIN_SWAP_AMOUNT = config("MIN_SWAP_AMOUNT", cast=int, default=1_000_000) + + +class OnchainPayment(models.Model): + class Concepts(models.IntegerChoices): + PAYBUYER = 3, "Payment to buyer" + + class Status(models.IntegerChoices): + CREAT = 0, "Created" # User was given platform fees and suggested mining fees + VALID = 1, "Valid" # Valid onchain address and fee submitted + MEMPO = 2, "In mempool" # Tx is sent to mempool + CONFI = 3, "Confirmed" # Tx is confirmed +2 blocks + CANCE = 4, "Cancelled" # Cancelled tx + QUEUE = 5, "Queued" # Payment is queued to be sent out + + def get_balance(): + balance = BalanceLog.objects.create() + return balance.time + + # payment use details + concept = models.PositiveSmallIntegerField( + choices=Concepts.choices, null=False, default=Concepts.PAYBUYER + ) + status = models.PositiveSmallIntegerField( + choices=Status.choices, null=False, default=Status.CREAT + ) + + broadcasted = models.BooleanField(default=False, null=False, blank=False) + + # payment info + address = models.CharField( + max_length=100, unique=False, default=None, null=True, blank=True + ) + + txid = models.CharField( + max_length=64, unique=True, null=True, default=None, blank=True + ) + + num_satoshis = models.PositiveBigIntegerField( + null=True, + validators=[ + MinValueValidator(0.5 * MIN_SWAP_AMOUNT), + MaxValueValidator(1.5 * MAX_TRADE), + ], + ) + sent_satoshis = models.PositiveBigIntegerField( + null=True, + validators=[ + MinValueValidator(0.5 * MIN_SWAP_AMOUNT), + MaxValueValidator(1.5 * MAX_TRADE), + ], + ) + # fee in sats/vbyte with mSats decimals fee_msat + suggested_mining_fee_rate = models.DecimalField( + max_digits=6, + decimal_places=3, + default=2.05, + null=False, + blank=False, + validators=[MinValueValidator(1), MaxValueValidator(999)], + ) + mining_fee_rate = models.DecimalField( + max_digits=6, + decimal_places=3, + default=2.05, + null=False, + blank=False, + validators=[MinValueValidator(1), MaxValueValidator(999)], + ) + mining_fee_sats = models.PositiveBigIntegerField(default=0, null=False, blank=False) + + # platform onchain/channels balance at creation, swap fee rate as percent of total volume + balance = models.ForeignKey( + BalanceLog, + related_name="balance", + on_delete=models.SET_NULL, + null=True, + default=get_balance, + ) + + swap_fee_rate = models.DecimalField( + max_digits=4, + decimal_places=2, + default=config("MIN_SWAP_FEE", cast=float, default=0.01) * 100, + null=False, + blank=False, + ) + + created_at = models.DateTimeField(default=timezone.now) + + # involved parties + receiver = models.ForeignKey( + User, + related_name="tx_receiver", + on_delete=models.SET_NULL, + null=True, + default=None, + ) + + def __str__(self): + return f"TX-{str(self.id)}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}" + + class Meta: + verbose_name = "Onchain payment" + verbose_name_plural = "Onchain payments" + + @property + def hash(self): + # Display txid as 'hash' truncated + return truncatechars(self.txid, 10) diff --git a/api/models/order.py b/api/models/order.py new file mode 100644 index 00000000..06fd71aa --- /dev/null +++ b/api/models/order.py @@ -0,0 +1,285 @@ +import uuid + +from decouple import config +from django.contrib.auth.models import User +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models.signals import pre_delete +from django.dispatch import receiver +from django.utils import timezone + +MIN_TRADE = config("MIN_TRADE", cast=int, default=20_000) +MAX_TRADE = config("MAX_TRADE", cast=int, default=1_000_000) +FIAT_EXCHANGE_DURATION = config("FIAT_EXCHANGE_DURATION", cast=int, default=24) + + +class Order(models.Model): + class Types(models.IntegerChoices): + BUY = 0, "BUY" + SELL = 1, "SELL" + + class Status(models.IntegerChoices): + WFB = 0, "Waiting for maker bond" + PUB = 1, "Public" + PAU = 2, "Paused" + TAK = 3, "Waiting for taker bond" + UCA = 4, "Cancelled" + EXP = 5, "Expired" + WF2 = 6, "Waiting for trade collateral and buyer invoice" + WFE = 7, "Waiting only for seller trade collateral" + WFI = 8, "Waiting only for buyer invoice" + CHA = 9, "Sending fiat - In chatroom" + FSE = 10, "Fiat sent - In chatroom" + DIS = 11, "In dispute" + CCA = 12, "Collaboratively cancelled" + PAY = 13, "Sending satoshis to buyer" + SUC = 14, "Sucessful trade" + FAI = 15, "Failed lightning network routing" + WFR = 16, "Wait for dispute resolution" + MLD = 17, "Maker lost dispute" + TLD = 18, "Taker lost dispute" + + class ExpiryReasons(models.IntegerChoices): + NTAKEN = 0, "Expired not taken" + NMBOND = 1, "Maker bond not locked" + NESCRO = 2, "Escrow not locked" + NINVOI = 3, "Invoice not submitted" + NESINV = 4, "Neither escrow locked or invoice submitted" + + # order info + reference = models.UUIDField(default=uuid.uuid4, editable=False) + status = models.PositiveSmallIntegerField( + choices=Status.choices, null=False, default=Status.WFB + ) + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + expiry_reason = models.PositiveSmallIntegerField( + choices=ExpiryReasons.choices, null=True, blank=True, default=None + ) + + # order details + type = models.PositiveSmallIntegerField(choices=Types.choices, null=False) + currency = models.ForeignKey("api.Currency", null=True, on_delete=models.SET_NULL) + amount = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True) + has_range = models.BooleanField(default=False, null=False, blank=False) + min_amount = models.DecimalField( + max_digits=18, decimal_places=8, null=True, blank=True + ) + max_amount = models.DecimalField( + max_digits=18, decimal_places=8, null=True, blank=True + ) + payment_method = models.CharField( + max_length=70, null=False, default="not specified", blank=True + ) + # order pricing method. A explicit amount of sats, or a relative premium above/below market. + is_explicit = models.BooleanField(default=False, null=False) + # marked to market + premium = models.DecimalField( + max_digits=5, + decimal_places=2, + default=0, + null=True, + validators=[MinValueValidator(-100), MaxValueValidator(999)], + blank=True, + ) + # explicit + satoshis = models.PositiveBigIntegerField( + null=True, + validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], + blank=True, + ) + # optionally makers can choose the public order duration length (seconds) + public_duration = models.PositiveBigIntegerField( + default=60 * 60 * config("DEFAULT_PUBLIC_ORDER_DURATION", cast=int, default=24) + - 1, + null=False, + validators=[ + MinValueValidator( + 60 * 60 * config("MIN_PUBLIC_ORDER_DURATION", cast=float, default=0.166) + ), # Min is 10 minutes + MaxValueValidator( + 60 * 60 * config("MAX_PUBLIC_ORDER_DURATION", cast=float, default=24) + ), # Max is 24 Hours + ], + blank=False, + ) + + # optionally makers can choose the escrow lock / invoice submission step length (seconds) + escrow_duration = models.PositiveBigIntegerField( + default=60 * int(config("INVOICE_AND_ESCROW_DURATION")) - 1, + null=False, + validators=[ + MinValueValidator(60 * 30), # Min is 30 minutes + MaxValueValidator(60 * 60 * 8), # Max is 8 Hours + ], + blank=False, + ) + + # optionally makers can choose the fidelity bond size of the maker and taker (%) + bond_size = models.DecimalField( + max_digits=4, + decimal_places=2, + default=config("DEFAULT_BOND_SIZE", cast=float, default=3), + null=False, + validators=[ + MinValueValidator(config("MIN_BOND_SIZE", cast=float, default=1)), # 1 % + MaxValueValidator(config("MAX_BOND_SIZE", cast=float, default=1)), # 15 % + ], + blank=False, + ) + + # how many sats at creation and at last check (relevant for marked to market) + t0_satoshis = models.PositiveBigIntegerField( + null=True, + validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], + blank=True, + ) # sats at creation + last_satoshis = models.PositiveBigIntegerField( + null=True, + validators=[MinValueValidator(0), MaxValueValidator(MAX_TRADE * 2)], + blank=True, + ) # sats last time checked. Weird if 2* trade max... + # timestamp of last_satoshis + last_satoshis_time = models.DateTimeField(null=True, default=None, blank=True) + # time the fiat exchange is confirmed and Sats released to buyer + contract_finalization_time = models.DateTimeField( + null=True, default=None, blank=True + ) + # order participants + maker = models.ForeignKey( + User, related_name="maker", on_delete=models.SET_NULL, null=True, default=None + ) # unique = True, a maker can only make one order + taker = models.ForeignKey( + User, + related_name="taker", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) # unique = True, a taker can only take one order + maker_last_seen = models.DateTimeField(null=True, default=None, blank=True) + taker_last_seen = models.DateTimeField(null=True, default=None, blank=True) + + # When collaborative cancel is needed and one partner has cancelled. + maker_asked_cancel = models.BooleanField(default=False, null=False) + taker_asked_cancel = models.BooleanField(default=False, null=False) + + is_fiat_sent = models.BooleanField(default=False, null=False) + reverted_fiat_sent = models.BooleanField(default=False, null=False) + + # in dispute + is_disputed = models.BooleanField(default=False, null=False) + maker_statement = models.TextField( + max_length=50_000, null=True, default=None, blank=True + ) + taker_statement = models.TextField( + max_length=50_000, null=True, default=None, blank=True + ) + + # LNpayments + # Order collateral + maker_bond = models.OneToOneField( + "api.LNPayment", + related_name="order_made", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + taker_bond = models.OneToOneField( + "api.LNPayment", + related_name="order_taken", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + trade_escrow = models.OneToOneField( + "api.LNPayment", + related_name="order_escrow", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + # is buyer payout a LN invoice (false) or on chain address (true) + is_swap = models.BooleanField(default=False, null=False) + # buyer payment LN invoice + payout = models.OneToOneField( + "api.LNPayment", + related_name="order_paid_LN", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + # buyer payment address + payout_tx = models.OneToOneField( + "api.OnchainPayment", + related_name="order_paid_TX", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + + # ratings + maker_rated = models.BooleanField(default=False, null=False) + taker_rated = models.BooleanField(default=False, null=False) + maker_platform_rated = models.BooleanField(default=False, null=False) + taker_platform_rated = models.BooleanField(default=False, null=False) + + def __str__(self): + if self.has_range and self.amount is None: + amt = str(float(self.min_amount)) + "-" + str(float(self.max_amount)) + else: + amt = float(self.amount) + return f"Order {self.id}: {self.Types(self.type).label} BTC for {amt} {self.currency}" + + def t_to_expire(self, status): + + t_to_expire = { + 0: config( + "EXP_MAKER_BOND_INVOICE", cast=int, default=300 + ), # 'Waiting for maker bond' + 1: self.public_duration, # 'Public' + 2: 0, # 'Deleted' + 3: config( + "EXP_TAKER_BOND_INVOICE", cast=int, default=150 + ), # 'Waiting for taker bond' + 4: 0, # 'Cancelled' + 5: 0, # 'Expired' + 6: int( + self.escrow_duration + ), # 'Waiting for trade collateral and buyer invoice' + 7: int(self.escrow_duration), # 'Waiting only for seller trade collateral' + 8: int(self.escrow_duration), # 'Waiting only for buyer invoice' + 9: 60 * 60 * FIAT_EXCHANGE_DURATION, # 'Sending fiat - In chatroom' + 10: 60 * 60 * FIAT_EXCHANGE_DURATION, # 'Fiat sent - In chatroom' + 11: 1 * 24 * 60 * 60, # 'In dispute' + 12: 0, # 'Collaboratively cancelled' + 13: 100 * 24 * 60 * 60, # 'Sending satoshis to buyer' + 14: 100 * 24 * 60 * 60, # 'Successful trade' + 15: 100 * 24 * 60 * 60, # 'Failed lightning network routing' + 16: 100 * 24 * 60 * 60, # 'Wait for dispute resolution' + 17: 100 * 24 * 60 * 60, # 'Maker lost dispute' + 18: 100 * 24 * 60 * 60, # 'Taker lost dispute' + } + + return t_to_expire[status] + + +@receiver(pre_delete, sender=Order) +def delete_lnpayment_at_order_deletion(sender, instance, **kwargs): + to_delete = ( + instance.maker_bond, + instance.payout, + instance.taker_bond, + instance.trade_escrow, + ) + + for lnpayment in to_delete: + try: + lnpayment.delete() + except Exception: + pass diff --git a/api/models/robot.py b/api/models/robot.py new file mode 100644 index 00000000..fe718be1 --- /dev/null +++ b/api/models/robot.py @@ -0,0 +1,137 @@ +from pathlib import Path + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.validators import ( + MaxValueValidator, + MinValueValidator, + validate_comma_separated_integer_list, +) +from django.db import models +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver +from django.utils.html import mark_safe + + +class Robot(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + + # PGP keys, used for E2E chat encryption. Priv key is encrypted with user's passphrase (highEntropyToken) + public_key = models.TextField( + # Actually only 400-500 characters for ECC, but other types might be longer + max_length=2000, + null=True, + default=None, + blank=True, + ) + encrypted_private_key = models.TextField( + max_length=2000, + null=True, + default=None, + blank=True, + ) + + # Total trades + total_contracts = models.PositiveIntegerField(null=False, default=0) + + # Ratings stored as a comma separated integer list + total_ratings = models.PositiveIntegerField(null=False, default=0) + latest_ratings = models.CharField( + max_length=999, + null=True, + default=None, + validators=[validate_comma_separated_integer_list], + blank=True, + ) # Will only store latest rating + avg_rating = models.DecimalField( + max_digits=4, + decimal_places=1, + default=None, + null=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + blank=True, + ) + # Used to deep link telegram chat in case telegram notifications are enabled + telegram_token = models.CharField(max_length=20, null=True, blank=True) + telegram_chat_id = models.BigIntegerField(null=True, default=None, blank=True) + telegram_enabled = models.BooleanField(default=False, null=False) + telegram_lang_code = models.CharField(max_length=10, null=True, blank=True) + telegram_welcomed = models.BooleanField(default=False, null=False) + + # Referral program + is_referred = models.BooleanField(default=False, null=False) + referred_by = models.ForeignKey( + "self", + related_name="referee", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + referral_code = models.CharField(max_length=15, null=True, blank=True) + # Recent rewards from referred trades that will be "earned" at a later point to difficult espionage. + pending_rewards = models.PositiveIntegerField(null=False, default=0) + # Claimable rewards + earned_rewards = models.PositiveIntegerField(null=False, default=0) + # Total claimed rewards + claimed_rewards = models.PositiveIntegerField(null=False, default=0) + + # Disputes + num_disputes = models.PositiveIntegerField(null=False, default=0) + lost_disputes = models.PositiveIntegerField(null=False, default=0) + num_disputes_started = models.PositiveIntegerField(null=False, default=0) + orders_disputes_started = models.CharField( + max_length=999, + null=True, + default=None, + validators=[validate_comma_separated_integer_list], + blank=True, + ) # Will only store ID of orders + + # RoboHash + avatar = models.ImageField( + default=("static/assets/avatars/" + "unknown_avatar.png"), + verbose_name="Avatar", + blank=True, + ) + + # Penalty expiration (only used then taking/cancelling repeatedly orders in the book before comitting bond) + penalty_expiration = models.DateTimeField(null=True, default=None, blank=True) + + # Platform rate + platform_rating = models.PositiveIntegerField(null=True, default=None, blank=True) + + # Stealth invoices + wants_stealth = models.BooleanField(default=True, null=False) + + @receiver(post_save, sender=User) + def create_user_robot(sender, instance, created, **kwargs): + if created: + Robot.objects.create(user=instance) + + @receiver(post_save, sender=User) + def save_user_robot(sender, instance, **kwargs): + instance.robot.save() + + @receiver(pre_delete, sender=User) + def del_avatar_from_disk(sender, instance, **kwargs): + try: + avatar_file = Path( + settings.AVATAR_ROOT + instance.robot.avatar.url.split("/")[-1] + ) + avatar_file.unlink() + except Exception: + pass + + def __str__(self): + return self.user.username + + # to display avatars in admin panel + def get_avatar(self): + if not self.avatar: + return settings.STATIC_ROOT + "unknown_avatar.png" + return self.avatar.url + + # method to create a fake table field in read only mode + def avatar_tag(self): + return mark_safe('' % self.get_avatar()) diff --git a/api/nick_generator/dicts/en/nouns.py b/api/nick_generator/dicts/en/nouns.py index 0f743bef..c55e1819 100755 --- a/api/nick_generator/dicts/en/nouns.py +++ b/api/nick_generator/dicts/en/nouns.py @@ -7540,7 +7540,7 @@ nouns = [ "Professorship", "Profet", "Proficiency", - "Profile", + "Robot", "Profit", "Profitability", "Profoundity", @@ -12095,7 +12095,7 @@ nouns = [ "Profession", "Professional", "Professor", - "Profile", + "Robot", "Profit", "Program", "Progress", diff --git a/api/nick_generator/nick_generator.py b/api/nick_generator/nick_generator.py index bf40fa8a..dc9ea2cf 100755 --- a/api/nick_generator/nick_generator.py +++ b/api/nick_generator/nick_generator.py @@ -3,19 +3,18 @@ import time from .utils import human_format -""" -Deterministic nick generator from SHA256 hash. - -It builds Nicknames as: -Adverb + Adjective + Noun + Numeric(0-999) - -With the current English dictionaries there -is a total of to 450*4800*12500*1000 = -28 Trillion deterministic nicks -""" - class NickGenerator: + """ + Deterministic nick generator from SHA256 hash. + + It builds Nicknames as: + Adverb + Adjective + Noun + Numeric(0-999) + + With the current English dictionaries there + is a total of to 450*4800*12500*1000 = + 28 Trillion deterministic nicks""" + def __init__( self, lang="English", @@ -128,7 +127,7 @@ class NickGenerator: self, primer_hash=None, max_length=25, - max_iter=10000, + max_iter=10_000, ): """ Generates Nicks that are short. @@ -149,7 +148,7 @@ class NickGenerator: i = i + 1 return "", 0, 0, i - def compute_pool_size_loss(self, max_length=22, max_iter=1000000, num_runs=5000): + def compute_pool_size_loss(self, max_length=22, max_iter=1_000_000, num_runs=5000): """ Computes median an average loss of nick pool diversity due to max_lenght @@ -162,7 +161,7 @@ class NickGenerator: attempts = [] for i in range(num_runs): - string = str(random.uniform(0, 1000000)) + string = str(random.uniform(0, 1_000_000)) hash = hashlib.sha256(str.encode(string)).hexdigest() _, _, pool_size, tries = self.short_from_SHA256(hash, max_length) @@ -188,7 +187,7 @@ if __name__ == "__main__": nick_lang = "English" # Spanish hash = hashlib.sha256(b"No one expected such cool nick!!").hexdigest() max_length = 22 - max_iter = 100000000 + max_iter = 100_000_000 # Initialized nick generator GenNick = NickGenerator(lang=nick_lang) @@ -215,7 +214,7 @@ if __name__ == "__main__": random.seed(1) for i in range(100): - string = str(random.uniform(0, 1000000)) + string = str(random.uniform(0, 1_000_000)) hash = hashlib.sha256(str.encode(string)).hexdigest() print( GenNick.short_from_SHA256(hash, max_length=max_length, max_iter=max_iter)[0] diff --git a/api/notifications.py b/api/notifications.py index 3358ec12..0f666f67 100644 --- a/api/notifications.py +++ b/api/notifications.py @@ -15,16 +15,16 @@ class Telegram: def get_context(user): """returns context needed to enable TG notifications""" context = {} - if user.profile.telegram_enabled: + if user.robot.telegram_enabled: context["tg_enabled"] = True else: context["tg_enabled"] = False - if user.profile.telegram_token is None: - user.profile.telegram_token = token_urlsafe(15) - user.profile.save() + if user.robot.telegram_token is None: + user.robot.telegram_token = token_urlsafe(15) + user.robot.save() - context["tg_token"] = user.profile.telegram_token + context["tg_token"] = user.robot.telegram_token context["tg_bot_name"] = config("TELEGRAM_BOT_NAME") return context @@ -46,103 +46,103 @@ class Telegram: def welcome(self, user): """User enabled Telegram Notifications""" - lang = user.profile.telegram_lang_code + lang = user.robot.telegram_lang_code if lang == "es": text = f"🔔 Hola {user.username}, te enviaré notificaciones sobre tus órdenes en RoboSats." else: text = f"🔔 Hey {user.username}, I will send you notifications about your RoboSats orders." - self.send_message(user.profile.telegram_chat_id, text) - user.profile.telegram_welcomed = True - user.profile.save() + self.send_message(user.robot.telegram_chat_id, text) + user.robot.telegram_welcomed = True + user.robot.save() return def order_taken_confirmed(self, order): - if order.maker.profile.telegram_enabled: - lang = order.maker.profile.telegram_lang_code + if order.maker.robot.telegram_enabled: + lang = order.maker.robot.telegram_lang_code if lang == "es": text = f"✅ Hey {order.maker.username} ¡Tu orden con ID {order.id} ha sido tomada por {order.taker.username}!🥳 Visita http://{self.site}/order/{order.id} para continuar." else: text = f"✅ Hey {order.maker.username}, your order was taken by {order.taker.username}!🥳 Visit http://{self.site}/order/{order.id} to proceed with the trade." - self.send_message(order.maker.profile.telegram_chat_id, text) + self.send_message(order.maker.robot.telegram_chat_id, text) - if order.taker.profile.telegram_enabled: - lang = order.taker.profile.telegram_lang_code + if order.taker.robot.telegram_enabled: + lang = order.taker.robot.telegram_lang_code if lang == "es": text = f"✅ Hey {order.taker.username}, acabas de tomar la orden con ID {order.id}." else: text = f"✅ Hey {order.taker.username}, you just took the order with ID {order.id}." - self.send_message(order.taker.profile.telegram_chat_id, text) + self.send_message(order.taker.robot.telegram_chat_id, text) return def fiat_exchange_starts(self, order): for user in [order.maker, order.taker]: - if user.profile.telegram_enabled: - lang = user.profile.telegram_lang_code + if user.robot.telegram_enabled: + lang = user.robot.telegram_lang_code if lang == "es": text = f"✅ Hey {user.username}, el depósito de garantía y el recibo del comprador han sido recibidos. Es hora de enviar el dinero fiat. Visita http://{self.site}/order/{order.id} para hablar con tu contraparte." else: text = f"✅ Hey {user.username}, the escrow and invoice have been submitted. The fiat exchange starts now via the platform chat. Visit http://{self.site}/order/{order.id} to talk with your counterpart." - self.send_message(user.profile.telegram_chat_id, text) + self.send_message(user.robot.telegram_chat_id, text) return def order_expired_untaken(self, order): - if order.maker.profile.telegram_enabled: - lang = order.maker.profile.telegram_lang_code + if order.maker.robot.telegram_enabled: + lang = order.maker.robot.telegram_lang_code if lang == "es": text = f"😪 Hey {order.maker.username}, tu orden con ID {order.id} ha expirado sin ser tomada por ningún robot. Visita http://{self.site}/order/{order.id} para renovarla." else: text = f"😪 Hey {order.maker.username}, your order with ID {order.id} has expired without a taker. Visit http://{self.site}/order/{order.id} to renew it." - self.send_message(order.maker.profile.telegram_chat_id, text) + self.send_message(order.maker.robot.telegram_chat_id, text) return def trade_successful(self, order): for user in [order.maker, order.taker]: - if user.profile.telegram_enabled: - lang = user.profile.telegram_lang_code + if user.robot.telegram_enabled: + lang = user.robot.telegram_lang_code if lang == "es": text = f"🥳 ¡Tu orden con ID {order.id} ha finalizado exitosamente!⚡ Únete a nosotros en @robosats_es y ayúdanos a mejorar." else: text = f"🥳 Your order with ID {order.id} has finished successfully!⚡ Join us @robosats and help us improve." - self.send_message(user.profile.telegram_chat_id, text) + self.send_message(user.robot.telegram_chat_id, text) return def public_order_cancelled(self, order): - if order.maker.profile.telegram_enabled: - lang = order.maker.profile.telegram_lang_code + if order.maker.robot.telegram_enabled: + lang = order.maker.robot.telegram_lang_code if lang == "es": text = f"❌ Hey {order.maker.username}, has cancelado tu orden pública con ID {order.id}." else: text = f"❌ Hey {order.maker.username}, you have cancelled your public order with ID {order.id}." - self.send_message(order.maker.profile.telegram_chat_id, text) + self.send_message(order.maker.robot.telegram_chat_id, text) return def collaborative_cancelled(self, order): for user in [order.maker, order.taker]: - if user.profile.telegram_enabled: - lang = user.profile.telegram_lang_code + if user.robot.telegram_enabled: + lang = user.robot.telegram_lang_code if lang == "es": text = f"❌ Hey {user.username}, tu orden con ID {str(order.id)} fue cancelada colaborativamente." else: text = f"❌ Hey {user.username}, your order with ID {str(order.id)} has been collaboratively cancelled." - self.send_message(user.profile.telegram_chat_id, text) + self.send_message(user.robot.telegram_chat_id, text) return def dispute_opened(self, order): for user in [order.maker, order.taker]: - if user.profile.telegram_enabled: - lang = user.profile.telegram_lang_code + if user.robot.telegram_enabled: + lang = user.robot.telegram_lang_code if lang == "es": text = f"⚖️ Hey {user.username}, la orden con ID {str(order.id)} ha entrado en disputa." else: text = f"⚖️ Hey {user.username}, a dispute has been opened on your order with ID {str(order.id)}." - self.send_message(user.profile.telegram_chat_id, text) + self.send_message(user.robot.telegram_chat_id, text) return def order_published(self, order): - if order.maker.profile.telegram_enabled: - lang = order.maker.profile.telegram_lang_code + if order.maker.robot.telegram_enabled: + lang = order.maker.robot.telegram_lang_code # In weird cases the order cannot be found (e.g. it is cancelled) queryset = Order.objects.filter(maker=order.maker) if len(queryset) == 0: @@ -152,7 +152,7 @@ class Telegram: text = f"✅ Hey {order.maker.username}, tu orden con ID {str(order.id)} es pública en el libro de ordenes." else: text = f"✅ Hey {order.maker.username}, your order with ID {str(order.id)} is public in the order book." - self.send_message(order.maker.profile.telegram_chat_id, text) + self.send_message(order.maker.robot.telegram_chat_id, text) return def new_chat_message(self, order, chat_message): @@ -180,8 +180,8 @@ class Telegram: notification_reason = f"(You receive this notification because this was the first in-chat message. You will only be notified again if there is a gap bigger than {TIMEGAP} minutes between messages)" user = chat_message.receiver - if user.profile.telegram_enabled: + if user.robot.telegram_enabled: text = f"💬 Hey {user.username}, a new chat message in-app was sent to you by {chat_message.sender.username} for order ID {str(order.id)}. {notification_reason}" - self.send_message(user.profile.telegram_chat_id, text) + self.send_message(user.robot.telegram_chat_id, text) return diff --git a/api/serializers.py b/api/serializers.py index b33d8314..d539cde4 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -484,7 +484,7 @@ class UpdateOrderSerializer(serializers.Serializer): routing_budget_ppm = serializers.IntegerField( default=0, min_value=0, - max_value=100001, + max_value=100_001, allow_null=True, required=False, help_text="Max budget to allocate for routing in PPM", @@ -493,7 +493,7 @@ class UpdateOrderSerializer(serializers.Serializer): max_length=100, allow_null=True, allow_blank=True, default=None ) statement = serializers.CharField( - max_length=500000, allow_null=True, allow_blank=True, default=None + max_length=500_000, allow_null=True, allow_blank=True, default=None ) action = serializers.ChoiceField( choices=( diff --git a/api/tasks.py b/api/tasks.py index 20f5c50d..b9fba25c 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -22,16 +22,16 @@ def users_cleansing(): # And do not have an active trade, any past contract or any reward. deleted_users = [] for user in queryset: - # Try an except, due to unknown cause for users lacking profiles. + # Try an except, due to unknown cause for users lacking robots. try: if ( - user.profile.pending_rewards > 0 - or user.profile.earned_rewards > 0 - or user.profile.claimed_rewards > 0 - or user.profile.telegram_enabled is True + user.robot.pending_rewards > 0 + or user.robot.earned_rewards > 0 + or user.robot.claimed_rewards > 0 + or user.robot.telegram_enabled is True ): continue - if not user.profile.total_contracts == 0: + if not user.robot.total_contracts == 0: continue valid, _, _ = Logics.validate_already_maker_or_taker(user) if valid: @@ -53,22 +53,22 @@ def give_rewards(): Referral rewards go from pending to earned. Happens asynchronously so the referral program cannot be easily used to spy. """ - from api.models import Profile + from api.models import Robot # Users who's last login has not been in the last 6 hours - queryset = Profile.objects.filter(pending_rewards__gt=0) + queryset = Robot.objects.filter(pending_rewards__gt=0) # And do not have an active trade, any past contract or any reward. results = {} - for profile in queryset: - given_reward = profile.pending_rewards - profile.earned_rewards += given_reward - profile.pending_rewards = 0 - profile.save() + for robot in queryset: + given_reward = robot.pending_rewards + robot.earned_rewards += given_reward + robot.pending_rewards = 0 + robot.save() - results[profile.user.username] = { + results[robot.user.username] = { "given_reward": given_reward, - "earned_rewards": profile.earned_rewards, + "earned_rewards": robot.earned_rewards, } return results @@ -93,7 +93,7 @@ def follow_send_payment(hash): # Default is 0ppm. Set by the user over API. Client's default is 1000 ppm. fee_limit_sat = int( - float(lnpayment.num_satoshis) * float(lnpayment.routing_budget_ppm) / 1000000 + float(lnpayment.num_satoshis) * float(lnpayment.routing_budget_ppm) / 1_000_000 ) timeout_seconds = config("PAYOUT_TIMEOUT_SECONDS", cast=int, default=90) @@ -214,10 +214,8 @@ def send_notification(order_id=None, chat_message_id=None, message=None): chat_message = Message.objects.get(id=chat_message_id) order = chat_message.order - taker_enabled = ( - False if order.taker is None else order.taker.profile.telegram_enabled - ) - if not (order.maker.profile.telegram_enabled or taker_enabled): + taker_enabled = False if order.taker is None else order.taker.robot.telegram_enabled + if not (order.maker.robot.telegram_enabled or taker_enabled): return from api.notifications import Telegram diff --git a/api/views.py b/api/views.py index f7caffef..c9885896 100644 --- a/api/views.py +++ b/api/views.py @@ -19,7 +19,7 @@ from robohash import Robohash from scipy.stats import entropy from api.logics import Logics -from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, Profile +from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, Robot from api.notifications import Telegram from api.oas_schemas import ( BookViewSchema, @@ -231,7 +231,7 @@ class OrderView(viewsets.ViewSet): # if user is under a limit (penalty), inform him. is_penalized, time_out = Logics.is_penalized(request.user) if is_penalized: - data["penalty"] = request.user.profile.penalty_expiration + data["penalty"] = request.user.robot.penalty_expiration # Add booleans if user is maker, taker, partipant, buyer or seller data["is_maker"] = order.maker == request.user @@ -726,28 +726,28 @@ class UserView(APIView): login(request, user) context["referral_code"] = token_urlsafe(8) - user.profile.referral_code = context["referral_code"] - user.profile.avatar = "static/assets/avatars/" + nickname + ".webp" + user.robot.referral_code = context["referral_code"] + user.robot.avatar = "static/assets/avatars/" + nickname + ".webp" # Noticed some PGP keys replaced at re-login. Should not happen. - # Let's implement this sanity check "If profile has not keys..." - if not user.profile.public_key: - user.profile.public_key = public_key - if not user.profile.encrypted_private_key: - user.profile.encrypted_private_key = encrypted_private_key + # Let's implement this sanity check "If robot has not keys..." + if not user.robot.public_key: + user.robot.public_key = public_key + if not user.robot.encrypted_private_key: + user.robot.encrypted_private_key = encrypted_private_key # If the ref_code was created by another robot, this robot was referred. - queryset = Profile.objects.filter(referral_code=ref_code) + queryset = Robot.objects.filter(referral_code=ref_code) if len(queryset) == 1: - user.profile.is_referred = True - user.profile.referred_by = queryset[0] + user.robot.is_referred = True + user.robot.referred_by = queryset[0] - user.profile.save() + user.robot.save() context = {**context, **Telegram.get_context(user)} - context["public_key"] = user.profile.public_key - context["encrypted_private_key"] = user.profile.encrypted_private_key - context["wants_stealth"] = user.profile.wants_stealth + context["public_key"] = user.robot.public_key + context["encrypted_private_key"] = user.robot.encrypted_private_key + context["wants_stealth"] = user.robot.wants_stealth return Response(context, status=status.HTTP_201_CREATED) # log in user and return pub/priv keys if existing @@ -755,11 +755,11 @@ class UserView(APIView): user = authenticate(request, username=nickname, password=token_sha256) if user is not None: login(request, user) - context["public_key"] = user.profile.public_key - context["encrypted_private_key"] = user.profile.encrypted_private_key - context["earned_rewards"] = user.profile.earned_rewards - context["referral_code"] = str(user.profile.referral_code) - context["wants_stealth"] = user.profile.wants_stealth + context["public_key"] = user.robot.public_key + context["encrypted_private_key"] = user.robot.encrypted_private_key + context["earned_rewards"] = user.robot.earned_rewards + context["referral_code"] = str(user.robot.referral_code) + context["wants_stealth"] = user.robot.wants_stealth # Adds/generate telegram token and whether it is enabled context = {**context, **Telegram.get_context(user)} @@ -806,8 +806,8 @@ class UserView(APIView): }, status.HTTP_400_BAD_REQUEST, ) - # Check if has already a profile with - if user.profile.total_contracts > 0: + # Check if has already a robot with + if user.robot.total_contracts > 0: return Response( { "bad_request": "Maybe a mistake? User cannot be deleted as it has completed trades" @@ -1017,8 +1017,8 @@ class LimitView(ListAPIView): def get(self, request): # Trade limits as BTC - min_trade = float(config("MIN_TRADE")) / 100000000 - max_trade = float(config("MAX_TRADE")) / 100000000 + min_trade = float(config("MIN_TRADE")) / 100_000_000 + max_trade = float(config("MAX_TRADE")) / 100_000_000 payload = {} queryset = Currency.objects.all().order_by("currency") @@ -1070,7 +1070,7 @@ class StealthView(UpdateAPIView): stealth = serializer.data.get("wantsStealth") - request.user.profile.wants_stealth = stealth - request.user.profile.save() + request.user.robot.wants_stealth = stealth + request.user.robot.save() return Response({"wantsStealth": stealth}) diff --git a/chat/consumers.py b/chat/consumers.py index 7dc0cfb4..5d4914ab 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -118,10 +118,10 @@ class ChatRoomConsumer(AsyncWebsocketConsumer): order = Order.objects.get(id=self.order_id) if order.maker == self.user: - return order.taker.profile.public_key + return order.taker.robot.public_key if order.taker == self.user: - return order.maker.profile.public_key + return order.maker.robot.public_key @database_sync_to_async def get_all_PGP_messages(self): diff --git a/chat/models.py b/chat/models.py index 41c361fa..95b9ecdb 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,7 +1,8 @@ +from django.contrib.auth.models import User from django.db import models from django.utils import timezone -from api.models import Order, User +from api.models import Order class ChatRoom(models.Model): diff --git a/chat/views.py b/chat/views.py index fbc6ba4c..1186911a 100644 --- a/chat/views.py +++ b/chat/views.py @@ -2,11 +2,12 @@ from datetime import timedelta from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +from django.contrib.auth.models import User from django.utils import timezone from rest_framework import status, viewsets from rest_framework.response import Response -from api.models import Order, User +from api.models import Order from api.tasks import send_notification from chat.models import ChatRoom, Message from chat.serializers import ChatSerializer, PostMessageSerializer @@ -69,7 +70,7 @@ class ChatView(viewsets.ViewSet): chatroom.maker_connected = True chatroom.save() peer_connected = chatroom.taker_connected - peer_public_key = order.taker.profile.public_key + peer_public_key = order.taker.robot.public_key elif chatroom.taker == request.user: chatroom.maker_connected = order.maker_last_seen > ( timezone.now() - timedelta(minutes=1) @@ -77,7 +78,7 @@ class ChatView(viewsets.ViewSet): chatroom.taker_connected = True chatroom.save() peer_connected = chatroom.maker_connected - peer_public_key = order.maker.profile.public_key + peer_public_key = order.maker.robot.public_key messages = [] for message in queryset: diff --git a/control/tasks.py b/control/tasks.py index 8f7ddf3c..650661e1 100644 --- a/control/tasks.py +++ b/control/tasks.py @@ -13,7 +13,7 @@ def do_accounting(): from django.db.models import Sum from django.utils import timezone - from api.models import LNPayment, MarketTick, OnchainPayment, Order, Profile + from api.models import LNPayment, MarketTick, OnchainPayment, Order, Robot from control.models import AccountingDay all_payments = LNPayment.objects.all() @@ -149,11 +149,11 @@ def do_accounting(): else: outstanding_pending_disputes = 0 - accounted_day.outstanding_earned_rewards = Profile.objects.all().aggregate( + accounted_day.outstanding_earned_rewards = Robot.objects.all().aggregate( Sum("earned_rewards") )["earned_rewards__sum"] accounted_day.outstanding_pending_disputes = outstanding_pending_disputes - accounted_day.lifetime_rewards_claimed = Profile.objects.all().aggregate( + accounted_day.lifetime_rewards_claimed = Robot.objects.all().aggregate( Sum("claimed_rewards") )["claimed_rewards__sum"] if accounted_yesterday is not None: