mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-14 19:36:24 +00:00
75f04579ed
* Refactor models into a module * Rename Profile model as Robot * Underscore numeric literals
1077 lines
42 KiB
Python
1077 lines
42 KiB
Python
import hashlib
|
|
from datetime import datetime, timedelta
|
|
from math import log2
|
|
from pathlib import Path
|
|
from secrets import token_urlsafe
|
|
|
|
from decouple import config
|
|
from django.conf import settings
|
|
from django.contrib.auth import authenticate, login, logout
|
|
from django.contrib.auth.models import User
|
|
from django.db.models import Q, Sum
|
|
from django.utils import timezone
|
|
from drf_spectacular.utils import extend_schema
|
|
from rest_framework import status, viewsets
|
|
from rest_framework.generics import CreateAPIView, ListAPIView, UpdateAPIView
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from robohash import Robohash
|
|
from scipy.stats import entropy
|
|
|
|
from api.logics import Logics
|
|
from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, Robot
|
|
from api.notifications import Telegram
|
|
from api.oas_schemas import (
|
|
BookViewSchema,
|
|
HistoricalViewSchema,
|
|
InfoViewSchema,
|
|
LimitViewSchema,
|
|
MakerViewSchema,
|
|
OrderViewSchema,
|
|
PriceViewSchema,
|
|
RewardViewSchema,
|
|
StealthViewSchema,
|
|
TickViewSchema,
|
|
UserViewSchema,
|
|
)
|
|
from api.serializers import (
|
|
ClaimRewardSerializer,
|
|
InfoSerializer,
|
|
ListOrderSerializer,
|
|
MakeOrderSerializer,
|
|
OrderPublicSerializer,
|
|
PriceSerializer,
|
|
StealthSerializer,
|
|
TickSerializer,
|
|
UpdateOrderSerializer,
|
|
UserGenSerializer,
|
|
)
|
|
from api.utils import (
|
|
compute_avg_premium,
|
|
compute_premium_percentile,
|
|
get_lnd_version,
|
|
get_robosats_commit,
|
|
get_robosats_version,
|
|
)
|
|
from chat.models import Message
|
|
from control.models import AccountingDay, BalanceLog
|
|
|
|
from .nick_generator.nick_generator import NickGenerator
|
|
|
|
EXP_MAKER_BOND_INVOICE = int(config("EXP_MAKER_BOND_INVOICE"))
|
|
RETRY_TIME = int(config("RETRY_TIME"))
|
|
PUBLIC_DURATION = 60 * 60 * int(config("DEFAULT_PUBLIC_ORDER_DURATION")) - 1
|
|
ESCROW_DURATION = 60 * int(config("INVOICE_AND_ESCROW_DURATION"))
|
|
BOND_SIZE = int(config("DEFAULT_BOND_SIZE"))
|
|
|
|
avatar_path = Path(settings.AVATAR_ROOT)
|
|
avatar_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create your views here.
|
|
|
|
|
|
class MakerView(CreateAPIView):
|
|
serializer_class = MakeOrderSerializer
|
|
|
|
@extend_schema(**MakerViewSchema.post)
|
|
def post(self, request):
|
|
serializer = self.serializer_class(data=request.data)
|
|
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{"bad_request": "Woops! It seems you do not have a robot avatar"},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# In case it gets overwhelming. Limit the number of public orders.
|
|
if Order.objects.filter(status=Order.Status.PUB).count() >= int(
|
|
config("MAX_PUBLIC_ORDERS")
|
|
):
|
|
return Response(
|
|
{
|
|
"bad_request": "Woah! RoboSats' book is at full capacity! Try again later"
|
|
},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
# Only allow users who are not already engaged in an order
|
|
valid, context, _ = Logics.validate_already_maker_or_taker(request.user)
|
|
if not valid:
|
|
return Response(context, status.HTTP_409_CONFLICT)
|
|
|
|
type = serializer.data.get("type")
|
|
currency = serializer.data.get("currency")
|
|
amount = serializer.data.get("amount")
|
|
has_range = serializer.data.get("has_range")
|
|
min_amount = serializer.data.get("min_amount")
|
|
max_amount = serializer.data.get("max_amount")
|
|
payment_method = serializer.data.get("payment_method")
|
|
premium = serializer.data.get("premium")
|
|
satoshis = serializer.data.get("satoshis")
|
|
is_explicit = serializer.data.get("is_explicit")
|
|
public_duration = serializer.data.get("public_duration")
|
|
escrow_duration = serializer.data.get("escrow_duration")
|
|
bond_size = serializer.data.get("bond_size")
|
|
|
|
# Optional params
|
|
if public_duration is None:
|
|
public_duration = PUBLIC_DURATION
|
|
if escrow_duration is None:
|
|
escrow_duration = ESCROW_DURATION
|
|
if bond_size is None:
|
|
bond_size = BOND_SIZE
|
|
if has_range is None:
|
|
has_range = False
|
|
|
|
# TODO add a check - if `is_explicit` is true then `satoshis` need to be specified
|
|
|
|
# An order can either have an amount or a range (min_amount and max_amount)
|
|
if has_range:
|
|
amount = None
|
|
else:
|
|
min_amount = None
|
|
max_amount = None
|
|
|
|
# Either amount or min_max has to be specified.
|
|
if has_range and (min_amount is None or max_amount is None):
|
|
return Response(
|
|
{
|
|
"bad_request": "You must specify min_amount and max_amount for a range order"
|
|
},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
elif not has_range and amount is None:
|
|
return Response(
|
|
{"bad_request": "You must specify an order amount"},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Creates a new order
|
|
order = Order(
|
|
type=type,
|
|
currency=Currency.objects.get(id=currency),
|
|
amount=amount,
|
|
has_range=has_range,
|
|
min_amount=min_amount,
|
|
max_amount=max_amount,
|
|
payment_method=payment_method,
|
|
premium=premium,
|
|
satoshis=satoshis,
|
|
is_explicit=is_explicit,
|
|
expires_at=timezone.now() + timedelta(seconds=EXP_MAKER_BOND_INVOICE),
|
|
maker=request.user,
|
|
public_duration=public_duration,
|
|
escrow_duration=escrow_duration,
|
|
bond_size=bond_size,
|
|
)
|
|
|
|
order.last_satoshis = order.t0_satoshis = Logics.satoshis_now(order)
|
|
|
|
valid, context = Logics.validate_order_size(order)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
order.save()
|
|
return Response(ListOrderSerializer(order).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class OrderView(viewsets.ViewSet):
|
|
serializer_class = UpdateOrderSerializer
|
|
lookup_url_kwarg = "order_id"
|
|
|
|
@extend_schema(**OrderViewSchema.get)
|
|
def get(self, request, format=None):
|
|
"""
|
|
Full trade pipeline takes place while looking/refreshing the order page.
|
|
"""
|
|
order_id = request.GET.get(self.lookup_url_kwarg)
|
|
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{
|
|
"bad_request": "You must have a robot avatar to see the order details"
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if order_id is None:
|
|
return Response(
|
|
{"bad_request": "Order ID parameter not found in request"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
order = Order.objects.filter(id=order_id)
|
|
|
|
# check if exactly one order is found in the db
|
|
if len(order) != 1:
|
|
return Response(
|
|
{"bad_request": "Invalid Order Id"}, status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
# This is our order.
|
|
order = order[0]
|
|
|
|
# 2) If order has been cancelled
|
|
if order.status == Order.Status.UCA:
|
|
return Response(
|
|
{"bad_request": "This order has been cancelled by the maker"},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if order.status == Order.Status.CCA:
|
|
return Response(
|
|
{"bad_request": "This order has been cancelled collaborativelly"},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
data = ListOrderSerializer(order).data
|
|
data["total_secs_exp"] = order.t_to_expire(order.status)
|
|
|
|
# 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.robot.penalty_expiration
|
|
|
|
# Add booleans if user is maker, taker, partipant, buyer or seller
|
|
data["is_maker"] = order.maker == request.user
|
|
data["is_taker"] = order.taker == request.user
|
|
data["is_participant"] = data["is_maker"] or data["is_taker"]
|
|
|
|
# 3.a) If not a participant and order is not public, forbid.
|
|
if not data["is_participant"] and order.status != Order.Status.PUB:
|
|
return Response(
|
|
{"bad_request": "This order is not available"},
|
|
status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# WRITE Update last_seen for maker and taker.
|
|
# Note down that the taker/maker was here recently, so counterpart knows if the user is paying attention.
|
|
data["maker_nick"] = str(order.maker)
|
|
if order.maker == request.user:
|
|
order.maker_last_seen = timezone.now()
|
|
order.save()
|
|
if order.taker == request.user:
|
|
order.taker_last_seen = timezone.now()
|
|
order.save()
|
|
|
|
# Add activity status of participants based on last_seen
|
|
if order.taker_last_seen is not None:
|
|
data["taker_status"] = Logics.user_activity_status(order.taker_last_seen)
|
|
if order.maker_last_seen is not None:
|
|
data["maker_status"] = Logics.user_activity_status(order.maker_last_seen)
|
|
|
|
# 3.b) Non participants can view details (but only if PUB)
|
|
if not data["is_participant"] and order.status == Order.Status.PUB:
|
|
data["price_now"], data["premium_now"] = Logics.price_and_premium_now(order)
|
|
data["satoshis_now"] = Logics.satoshis_now(order)
|
|
return Response(data, status=status.HTTP_200_OK)
|
|
|
|
# 4) If order is between public and WF2
|
|
if order.status >= Order.Status.PUB and order.status < Order.Status.WF2:
|
|
data["price_now"], data["premium_now"] = Logics.price_and_premium_now(order)
|
|
data["satoshis_now"] = Logics.satoshis_now(order)
|
|
|
|
# 4. a) If maker and Public/Paused, add premium percentile
|
|
# num similar orders, and maker information to enable telegram notifications.
|
|
if data["is_maker"] and order.status in [
|
|
Order.Status.PUB,
|
|
Order.Status.PAU,
|
|
]:
|
|
data["premium_percentile"] = compute_premium_percentile(order)
|
|
data["num_similar_orders"] = len(
|
|
Order.objects.filter(
|
|
currency=order.currency, status=Order.Status.PUB
|
|
)
|
|
)
|
|
|
|
# For participants add positions, nicks and status as a message and hold invoices status
|
|
data["is_buyer"] = Logics.is_buyer(order, request.user)
|
|
data["is_seller"] = Logics.is_seller(order, request.user)
|
|
data["taker_nick"] = str(order.taker)
|
|
data["status_message"] = Order.Status(order.status).label
|
|
data["is_fiat_sent"] = order.is_fiat_sent
|
|
data["is_disputed"] = order.is_disputed
|
|
data["ur_nick"] = request.user.username
|
|
data["satoshis_now"] = order.last_satoshis
|
|
|
|
# Add whether hold invoices are LOCKED (ACCEPTED)
|
|
# Is there a maker bond? If so, True if locked, False otherwise
|
|
if order.maker_bond:
|
|
data["maker_locked"] = order.maker_bond.status == LNPayment.Status.LOCKED
|
|
else:
|
|
data["maker_locked"] = False
|
|
|
|
# Is there a taker bond? If so, True if locked, False otherwise
|
|
if order.taker_bond:
|
|
data["taker_locked"] = order.taker_bond.status == LNPayment.Status.LOCKED
|
|
else:
|
|
data["taker_locked"] = False
|
|
|
|
# Is there an escrow? If so, True if locked, False otherwise
|
|
if order.trade_escrow:
|
|
data["escrow_locked"] = order.trade_escrow.status == LNPayment.Status.LOCKED
|
|
else:
|
|
data["escrow_locked"] = False
|
|
|
|
# If both bonds are locked, participants can see the final trade amount in sats.
|
|
if order.taker_bond:
|
|
if (
|
|
order.maker_bond.status
|
|
== order.taker_bond.status
|
|
== LNPayment.Status.LOCKED
|
|
):
|
|
# Seller sees the amount he sends
|
|
if data["is_seller"]:
|
|
data["trade_satoshis"] = Logics.escrow_amount(order, request.user)[
|
|
1
|
|
]["escrow_amount"]
|
|
# Buyer sees the amount he receives
|
|
elif data["is_buyer"]:
|
|
data["trade_satoshis"] = Logics.payout_amount(order, request.user)[
|
|
1
|
|
]["invoice_amount"]
|
|
|
|
# 5) If status is 'waiting for maker bond' and user is MAKER, reply with a MAKER hold invoice.
|
|
if order.status == Order.Status.WFB and data["is_maker"]:
|
|
valid, context = Logics.gen_maker_hold_invoice(order, request.user)
|
|
if valid:
|
|
data = {**data, **context}
|
|
else:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 6) If status is 'waiting for taker bond' and user is TAKER, reply with a TAKER hold invoice.
|
|
elif order.status == Order.Status.TAK and data["is_taker"]:
|
|
valid, context = Logics.gen_taker_hold_invoice(order, request.user)
|
|
if valid:
|
|
data = {**data, **context}
|
|
else:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 7 a. ) If seller and status is 'WF2' or 'WFE'
|
|
elif data["is_seller"] and (
|
|
order.status == Order.Status.WF2 or order.status == Order.Status.WFE
|
|
):
|
|
# If the two bonds are locked, reply with an ESCROW hold invoice.
|
|
if (
|
|
order.maker_bond.status
|
|
== order.taker_bond.status
|
|
== LNPayment.Status.LOCKED
|
|
):
|
|
valid, context = Logics.gen_escrow_hold_invoice(order, request.user)
|
|
if valid:
|
|
data = {**data, **context}
|
|
else:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 7.b) If user is Buyer and status is 'WF2' or 'WFI'
|
|
elif data["is_buyer"] and (
|
|
order.status == Order.Status.WF2 or order.status == Order.Status.WFI
|
|
):
|
|
|
|
# If the two bonds are locked, reply with an AMOUNT and onchain swap cost so he can send the buyer invoice/address.
|
|
if (
|
|
order.maker_bond.status
|
|
== order.taker_bond.status
|
|
== LNPayment.Status.LOCKED
|
|
):
|
|
valid, context = Logics.payout_amount(order, request.user)
|
|
if valid:
|
|
data = {**data, **context}
|
|
else:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 8) If status is 'CHA' or 'FSE' and all HTLCS are in LOCKED
|
|
elif order.status in [Order.Status.WFI, Order.Status.CHA, Order.Status.FSE]:
|
|
# If all bonds are locked.
|
|
if (
|
|
order.maker_bond.status
|
|
== order.taker_bond.status
|
|
== order.trade_escrow.status
|
|
== LNPayment.Status.LOCKED
|
|
):
|
|
# add whether a collaborative cancel is pending or has been asked
|
|
if (data["is_maker"] and order.taker_asked_cancel) or (
|
|
data["is_taker"] and order.maker_asked_cancel
|
|
):
|
|
data["pending_cancel"] = True
|
|
elif (data["is_maker"] and order.maker_asked_cancel) or (
|
|
data["is_taker"] and order.taker_asked_cancel
|
|
):
|
|
data["asked_for_cancel"] = True
|
|
else:
|
|
data["asked_for_cancel"] = False
|
|
|
|
# Add index of last chat message. To be used by client on Chat endpoint to fetch latest messages
|
|
messages = Message.objects.filter(order=order)
|
|
if len(messages) == 0:
|
|
data["chat_last_index"] = 0
|
|
else:
|
|
data["chat_last_index"] = messages.latest().index
|
|
|
|
# 9) If status is 'DIS' and all HTLCS are in LOCKED
|
|
elif order.status == Order.Status.DIS:
|
|
|
|
# add whether the dispute statement has been received
|
|
if data["is_maker"]:
|
|
data["statement_submitted"] = (
|
|
order.maker_statement is not None and order.maker_statement != ""
|
|
)
|
|
elif data["is_taker"]:
|
|
data["statement_submitted"] = (
|
|
order.taker_statement is not None and order.taker_statement != ""
|
|
)
|
|
|
|
# 9) If status is 'Failed routing', reply with retry amounts, time of next retry and ask for invoice at third.
|
|
elif (
|
|
order.status == Order.Status.FAI and order.payout.receiver == request.user
|
|
): # might not be the buyer if after a dispute where winner wins
|
|
data["retries"] = order.payout.routing_attempts
|
|
data["next_retry_time"] = order.payout.last_routing_time + timedelta(
|
|
minutes=RETRY_TIME
|
|
)
|
|
if order.payout.failure_reason:
|
|
data["failure_reason"] = LNPayment.FailureReason(
|
|
order.payout.failure_reason
|
|
).label
|
|
|
|
if order.payout.status == LNPayment.Status.EXPIRE:
|
|
data["invoice_expired"] = True
|
|
# Add invoice amount once again if invoice was expired.
|
|
data["trade_satoshis"] = Logics.payout_amount(order, request.user)[1][
|
|
"invoice_amount"
|
|
]
|
|
|
|
# 10) If status is 'Expired', "Sending", "Finished" or "failed routing", add info for renewal:
|
|
elif order.status in [
|
|
Order.Status.EXP,
|
|
Order.Status.SUC,
|
|
Order.Status.PAY,
|
|
Order.Status.FAI,
|
|
]:
|
|
data["public_duration"] = order.public_duration
|
|
data["bond_size"] = order.bond_size
|
|
|
|
# Adds trade summary
|
|
if order.status in [Order.Status.SUC, Order.Status.PAY, Order.Status.FAI]:
|
|
valid, context = Logics.summarize_trade(order, request.user)
|
|
if valid:
|
|
data = {**data, **context}
|
|
|
|
# If status is 'Expired' add expiry reason
|
|
if order.status == Order.Status.EXP:
|
|
data["expiry_reason"] = order.expiry_reason
|
|
data["expiry_message"] = Order.ExpiryReasons(order.expiry_reason).label
|
|
|
|
# If status is 'Succes' add final stats and txid if it is a swap
|
|
if order.status == Order.Status.SUC:
|
|
# If buyer and is a swap, add TXID
|
|
if Logics.is_buyer(order, request.user):
|
|
if order.is_swap:
|
|
data["num_satoshis"] = order.payout_tx.num_satoshis
|
|
data["sent_satoshis"] = order.payout_tx.sent_satoshis
|
|
data["network"] = str(config("NETWORK"))
|
|
if order.payout_tx.status in [
|
|
OnchainPayment.Status.MEMPO,
|
|
OnchainPayment.Status.CONFI,
|
|
]:
|
|
data["txid"] = order.payout_tx.txid
|
|
elif order.payout_tx.status == OnchainPayment.Status.QUEUE:
|
|
data["tx_queued"] = True
|
|
data["address"] = order.payout_tx.address
|
|
|
|
return Response(data, status.HTTP_200_OK)
|
|
|
|
@extend_schema(**OrderViewSchema.take_update_confirm_dispute_cancel)
|
|
def take_update_confirm_dispute_cancel(self, request, format=None):
|
|
"""
|
|
Here takes place all of the updates to the order object.
|
|
That is: take, confim, cancel, dispute, update_invoice or rate.
|
|
"""
|
|
order_id = request.GET.get(self.lookup_url_kwarg)
|
|
|
|
serializer = UpdateOrderSerializer(data=request.data)
|
|
if not serializer.is_valid():
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
order = Order.objects.get(id=order_id)
|
|
|
|
# action is either 1)'take', 2)'confirm', 2.b)'undo_confirm', 3)'cancel', 4)'dispute' , 5)'update_invoice'
|
|
# 5.b)'update_address' 6)'submit_statement' (in dispute), 7)'rate_user' , 8)'rate_platform'
|
|
action = serializer.data.get("action")
|
|
invoice = serializer.data.get("invoice")
|
|
routing_budget_ppm = serializer.data.get("routing_budget_ppm", 0)
|
|
address = serializer.data.get("address")
|
|
mining_fee_rate = serializer.data.get("mining_fee_rate")
|
|
statement = serializer.data.get("statement")
|
|
rating = serializer.data.get("rating")
|
|
|
|
# 1) If action is take, it is a taker request!
|
|
if action == "take":
|
|
if order.status == Order.Status.PUB:
|
|
valid, context, _ = Logics.validate_already_maker_or_taker(request.user)
|
|
if not valid:
|
|
return Response(context, status=status.HTTP_409_CONFLICT)
|
|
|
|
# For order with amount range, set the amount now.
|
|
if order.has_range:
|
|
amount = float(serializer.data.get("amount"))
|
|
valid, context = Logics.validate_amount_within_range(order, amount)
|
|
if not valid:
|
|
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
valid, context = Logics.take(order, request.user, amount)
|
|
else:
|
|
valid, context = Logics.take(order, request.user)
|
|
if not valid:
|
|
return Response(context, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
return self.get(request)
|
|
|
|
else:
|
|
Response(
|
|
{"bad_request": "This order is not public anymore."},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Any other action is only allowed if the user is a participant
|
|
if not (order.maker == request.user or order.taker == request.user):
|
|
return Response(
|
|
{"bad_request": "You are not a participant in this order"},
|
|
status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# 2) If action is 'update invoice'
|
|
elif action == "update_invoice":
|
|
valid, context = Logics.update_invoice(
|
|
order, request.user, invoice, routing_budget_ppm
|
|
)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 2.b) If action is 'update address'
|
|
elif action == "update_address":
|
|
valid, context = Logics.update_address(
|
|
order, request.user, address, mining_fee_rate
|
|
)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 3) If action is cancel
|
|
elif action == "cancel":
|
|
valid, context = Logics.cancel_order(order, request.user)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 4) If action is confirm
|
|
elif action == "confirm":
|
|
valid, context = Logics.confirm_fiat(order, request.user)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 4.b) If action is confirm
|
|
elif action == "undo_confirm":
|
|
valid, context = Logics.undo_confirm_fiat_sent(order, request.user)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 5) If action is dispute
|
|
elif action == "dispute":
|
|
valid, context = Logics.open_dispute(order, request.user)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
elif action == "submit_statement":
|
|
valid, context = Logics.dispute_statement(order, request.user, statement)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 6) If action is rate
|
|
elif action == "rate_user" and rating:
|
|
valid, context = Logics.rate_counterparty(order, request.user, rating)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 7) If action is rate_platform
|
|
elif action == "rate_platform" and rating:
|
|
valid, context = Logics.rate_platform(request.user, rating)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# 8) If action is rate_platform
|
|
elif action == "pause":
|
|
valid, context = Logics.pause_unpause_public_order(order, request.user)
|
|
if not valid:
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
# If nothing of the above... something else is going on. Probably not allowed!
|
|
else:
|
|
return Response(
|
|
{
|
|
"bad_request": "The Robotic Satoshis working in the warehouse did not understand you. "
|
|
+ "Please, fill a Bug Issue in Github https://github.com/reckless-satoshi/robosats/issues"
|
|
},
|
|
status.HTTP_501_NOT_IMPLEMENTED,
|
|
)
|
|
|
|
return self.get(request)
|
|
|
|
|
|
class UserView(APIView):
|
|
NickGen = NickGenerator(
|
|
lang="English", use_adv=False, use_adj=True, use_noun=True, max_num=999
|
|
)
|
|
|
|
serializer_class = UserGenSerializer
|
|
|
|
def post(self, request, format=None):
|
|
"""
|
|
Get a new user derived from a high entropy token
|
|
|
|
- Request has a hash of a high-entropy token
|
|
- Request includes pubKey and encrypted privKey
|
|
- Generates new nickname and avatar.
|
|
- Creates login credentials (new User object)
|
|
|
|
Response with Avatar, Nickname, pubKey, privKey.
|
|
"""
|
|
context = {}
|
|
serializer = self.serializer_class(data=request.data)
|
|
|
|
# Return bad request if serializer is not valid
|
|
if not serializer.is_valid():
|
|
context = {"bad_request": "Invalid serializer"}
|
|
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# The new way. The token is never sent. Only its SHA256
|
|
token_sha256 = serializer.data.get("token_sha256")
|
|
public_key = serializer.data.get("public_key")
|
|
encrypted_private_key = serializer.data.get("encrypted_private_key")
|
|
ref_code = serializer.data.get("ref_code")
|
|
|
|
# Now the server only receives a hash of the token. So server trusts the client
|
|
# with computing length, counts and unique_values to confirm the high entropy of the token
|
|
# In any case, it is up to the client if they want to create a bad high entropy token.
|
|
|
|
# Submitting the three params needed to compute token entropy is not mandatory
|
|
# If not submitted, avatars can be created with garbage entropy token. Frontend will always submit them.
|
|
try:
|
|
unique_values = serializer.data.get("unique_values")
|
|
counts = serializer.data.get("counts")
|
|
length = serializer.data.get("length")
|
|
|
|
shannon_entropy = entropy(counts, base=62)
|
|
bits_entropy = log2(unique_values**length)
|
|
|
|
# Payload
|
|
context = {
|
|
"token_shannon_entropy": shannon_entropy,
|
|
"token_bits_entropy": bits_entropy,
|
|
}
|
|
|
|
# Deny user gen if entropy below 128 bits or 0.7 shannon heterogeneity
|
|
if bits_entropy < 128 or shannon_entropy < 0.7:
|
|
context["bad_request"] = "The token does not have enough entropy"
|
|
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
|
except Exception:
|
|
pass
|
|
|
|
# Hash the token_sha256, only 1 iteration. (this is the second SHA256 of the user token, aka RoboSats ID)
|
|
hash = hashlib.sha256(token_sha256.encode("utf-8")).hexdigest()
|
|
|
|
# Generate nickname deterministically
|
|
nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0]
|
|
context["nickname"] = nickname
|
|
|
|
# Generate avatar
|
|
rh = Robohash(hash)
|
|
rh.assemble(roboset="set1", bgset="any") # for backgrounds ON
|
|
|
|
# Does not replace image if existing (avoid re-avatar in case of nick collusion)
|
|
# Deprecate "png" and keep "webp" only after v0.4.4
|
|
image_path = avatar_path.joinpath(nickname + ".webp")
|
|
if not image_path.exists():
|
|
with open(image_path, "wb") as f:
|
|
rh.img.save(f, format="WEBP", quality=80)
|
|
|
|
image_small_path = avatar_path.joinpath(nickname + ".small.webp")
|
|
with open(image_small_path, "wb") as f:
|
|
resized_img = rh.img.resize((80, 80))
|
|
resized_img.save(f, format="WEBP", quality=80)
|
|
|
|
png_path = avatar_path.joinpath(nickname + ".png")
|
|
with open(png_path, "wb") as f:
|
|
rh.img.save(f, format="png", optimize=True)
|
|
|
|
# Create new credentials and login if nickname is new
|
|
if len(User.objects.filter(username=nickname)) == 0:
|
|
if not public_key or not encrypted_private_key:
|
|
context[
|
|
"bad_request"
|
|
] = "Must provide valid 'pub' and 'enc_priv' PGP keys"
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
(
|
|
valid,
|
|
bad_keys_context,
|
|
public_key,
|
|
encrypted_private_key,
|
|
) = Logics.validate_pgp_keys(public_key, encrypted_private_key)
|
|
if not valid:
|
|
return Response(bad_keys_context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
User.objects.create_user(
|
|
username=nickname, password=token_sha256, is_staff=False
|
|
)
|
|
user = authenticate(request, username=nickname, password=token_sha256)
|
|
login(request, user)
|
|
|
|
context["referral_code"] = token_urlsafe(8)
|
|
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 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 = Robot.objects.filter(referral_code=ref_code)
|
|
if len(queryset) == 1:
|
|
user.robot.is_referred = True
|
|
user.robot.referred_by = queryset[0]
|
|
|
|
user.robot.save()
|
|
|
|
context = {**context, **Telegram.get_context(user)}
|
|
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
|
|
else:
|
|
user = authenticate(request, username=nickname, password=token_sha256)
|
|
if user is not None:
|
|
login(request, user)
|
|
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)}
|
|
|
|
# return active order or last made order if any
|
|
has_no_active_order, _, order = Logics.validate_already_maker_or_taker(
|
|
request.user
|
|
)
|
|
if not has_no_active_order:
|
|
context["active_order_id"] = order.id
|
|
else:
|
|
last_order = Order.objects.filter(
|
|
Q(maker=request.user) | Q(taker=request.user)
|
|
).last()
|
|
if last_order:
|
|
context["last_order_id"] = last_order.id
|
|
|
|
# Sends the welcome back message.
|
|
context["found"] = "We found your Robot avatar. Welcome back!"
|
|
return Response(context, status=status.HTTP_202_ACCEPTED)
|
|
else:
|
|
# It is unlikely, but maybe the nickname is taken (1 in 20 Billion chance)
|
|
context["found"] = "Bad luck, this nickname is taken"
|
|
context["bad_request"] = "Enter a different token"
|
|
return Response(context, status.HTTP_403_FORBIDDEN)
|
|
|
|
@extend_schema(**UserViewSchema.delete)
|
|
def delete(self, request):
|
|
"""Pressing "give me another" deletes the logged in user"""
|
|
user = request.user
|
|
if not user.is_authenticated:
|
|
return Response(status.HTTP_403_FORBIDDEN)
|
|
|
|
# Only delete if user life is shorter than 30 minutes. Helps to avoid deleting users by mistake
|
|
if user.date_joined < (timezone.now() - timedelta(minutes=30)):
|
|
return Response(status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Check if it is not a maker or taker!
|
|
not_participant, _, _ = Logics.validate_already_maker_or_taker(user)
|
|
if not not_participant:
|
|
return Response(
|
|
{
|
|
"bad_request": "Maybe a mistake? User cannot be deleted while he is part of an order"
|
|
},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
# 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"
|
|
},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
logout(request)
|
|
user.delete()
|
|
return Response(
|
|
{"user_deleted": "User deleted permanently"},
|
|
status.HTTP_301_MOVED_PERMANENTLY,
|
|
)
|
|
|
|
|
|
class BookView(ListAPIView):
|
|
serializer_class = OrderPublicSerializer
|
|
queryset = Order.objects.filter(status=Order.Status.PUB)
|
|
|
|
@extend_schema(**BookViewSchema.get)
|
|
def get(self, request, format=None):
|
|
currency = request.GET.get("currency", 0)
|
|
type = request.GET.get("type", 2)
|
|
|
|
queryset = Order.objects.filter(status=Order.Status.PUB)
|
|
|
|
# Currency 0 and type 2 are special cases treated as "ANY". (These are not really possible choices)
|
|
if int(currency) == 0 and int(type) != 2:
|
|
queryset = Order.objects.filter(type=type, status=Order.Status.PUB)
|
|
elif int(type) == 2 and int(currency) != 0:
|
|
queryset = Order.objects.filter(currency=currency, status=Order.Status.PUB)
|
|
elif not (int(currency) == 0 and int(type) == 2):
|
|
queryset = Order.objects.filter(
|
|
currency=currency, type=type, status=Order.Status.PUB
|
|
)
|
|
|
|
if len(queryset) == 0:
|
|
return Response(
|
|
{"not_found": "No orders found, be the first to make one"},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
book_data = []
|
|
for order in queryset:
|
|
data = ListOrderSerializer(order).data
|
|
data["maker_nick"] = str(order.maker)
|
|
|
|
data["satoshis_now"] = Logics.satoshis_now(order)
|
|
# Compute current premium for those orders that are explicitly priced.
|
|
data["price"], data["premium"] = Logics.price_and_premium_now(order)
|
|
data["maker_status"] = Logics.user_activity_status(order.maker_last_seen)
|
|
for key in (
|
|
"status",
|
|
"taker",
|
|
): # Non participants should not see the status or who is the taker
|
|
del data[key]
|
|
|
|
book_data.append(data)
|
|
|
|
return Response(book_data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class InfoView(ListAPIView):
|
|
|
|
serializer_class = InfoSerializer
|
|
|
|
@extend_schema(**InfoViewSchema.get)
|
|
def get(self, request):
|
|
context = {}
|
|
|
|
context["num_public_buy_orders"] = len(
|
|
Order.objects.filter(type=Order.Types.BUY, status=Order.Status.PUB)
|
|
)
|
|
context["num_public_sell_orders"] = len(
|
|
Order.objects.filter(type=Order.Types.SELL, status=Order.Status.PUB)
|
|
)
|
|
context["book_liquidity"] = Order.objects.filter(
|
|
status=Order.Status.PUB
|
|
).aggregate(Sum("last_satoshis"))["last_satoshis__sum"]
|
|
context["book_liquidity"] = (
|
|
0 if context["book_liquidity"] is None else context["book_liquidity"]
|
|
)
|
|
|
|
# Number of active users (logged in in last 30 minutes)
|
|
today = datetime.today()
|
|
context["active_robots_today"] = len(
|
|
User.objects.filter(last_login__day=today.day)
|
|
)
|
|
|
|
# Compute average premium and volume of today
|
|
last_day = timezone.now() - timedelta(days=1)
|
|
queryset = MarketTick.objects.filter(timestamp__gt=last_day)
|
|
if not len(queryset) == 0:
|
|
avg_premium, total_volume = compute_avg_premium(queryset)
|
|
# If no contracts, fallback to lifetime avg premium
|
|
else:
|
|
queryset = MarketTick.objects.all()
|
|
avg_premium, _ = compute_avg_premium(queryset)
|
|
total_volume = 0
|
|
|
|
queryset = MarketTick.objects.all()
|
|
if not len(queryset) == 0:
|
|
volume_contracted = []
|
|
for tick in queryset:
|
|
volume_contracted.append(tick.volume)
|
|
lifetime_volume = sum(volume_contracted)
|
|
else:
|
|
lifetime_volume = 0
|
|
|
|
context["last_day_nonkyc_btc_premium"] = round(avg_premium, 2)
|
|
context["last_day_volume"] = round(total_volume, 8)
|
|
context["lifetime_volume"] = round(lifetime_volume, 8)
|
|
context["lnd_version"] = get_lnd_version()
|
|
context["robosats_running_commit_hash"] = get_robosats_commit()
|
|
context["version"] = get_robosats_version()
|
|
context["alternative_site"] = config("ALTERNATIVE_SITE")
|
|
context["alternative_name"] = config("ALTERNATIVE_NAME")
|
|
context["node_alias"] = config("NODE_ALIAS")
|
|
context["node_id"] = config("NODE_ID")
|
|
context["network"] = config("NETWORK")
|
|
context["maker_fee"] = float(config("FEE")) * float(config("MAKER_FEE_SPLIT"))
|
|
context["taker_fee"] = float(config("FEE")) * (
|
|
1 - float(config("MAKER_FEE_SPLIT"))
|
|
)
|
|
context["bond_size"] = float(config("DEFAULT_BOND_SIZE"))
|
|
|
|
try:
|
|
context["current_swap_fee_rate"] = Logics.compute_swap_fee_rate(
|
|
BalanceLog.objects.latest("time")
|
|
)
|
|
except BalanceLog.DoesNotExist:
|
|
context["current_swap_fee_rate"] = 0
|
|
|
|
return Response(context, status.HTTP_200_OK)
|
|
|
|
|
|
class RewardView(CreateAPIView):
|
|
serializer_class = ClaimRewardSerializer
|
|
|
|
@extend_schema(**RewardViewSchema.post)
|
|
def post(self, request):
|
|
serializer = self.serializer_class(data=request.data)
|
|
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{"bad_request": "Woops! It seems you do not have a robot avatar"},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
invoice = serializer.data.get("invoice")
|
|
|
|
valid, context = Logics.withdraw_rewards(request.user, invoice)
|
|
|
|
if not valid:
|
|
context["successful_withdrawal"] = False
|
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
|
|
|
return Response({"successful_withdrawal": True}, status.HTTP_200_OK)
|
|
|
|
|
|
class PriceView(ListAPIView):
|
|
|
|
serializer_class = PriceSerializer
|
|
|
|
@extend_schema(**PriceViewSchema.get)
|
|
def get(self, request):
|
|
|
|
payload = {}
|
|
queryset = Currency.objects.all().order_by("currency")
|
|
|
|
for currency in queryset:
|
|
code = Currency.currency_dict[str(currency.currency)]
|
|
try:
|
|
last_tick = MarketTick.objects.filter(currency=currency).latest(
|
|
"timestamp"
|
|
)
|
|
payload[code] = {
|
|
"price": last_tick.price,
|
|
"volume": last_tick.volume,
|
|
"premium": last_tick.premium,
|
|
"timestamp": last_tick.timestamp,
|
|
}
|
|
except Exception:
|
|
payload[code] = None
|
|
|
|
return Response(payload, status.HTTP_200_OK)
|
|
|
|
|
|
class TickView(ListAPIView):
|
|
|
|
queryset = MarketTick.objects.all()
|
|
serializer_class = TickSerializer
|
|
|
|
@extend_schema(**TickViewSchema.get)
|
|
def get(self, request):
|
|
data = self.serializer_class(
|
|
self.queryset.all(), many=True, read_only=True
|
|
).data
|
|
return Response(data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class LimitView(ListAPIView):
|
|
@extend_schema(**LimitViewSchema.get)
|
|
def get(self, request):
|
|
|
|
# Trade limits as BTC
|
|
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")
|
|
|
|
for currency in queryset:
|
|
code = Currency.currency_dict[str(currency.currency)]
|
|
exchange_rate = float(currency.exchange_rate)
|
|
payload[currency.currency] = {
|
|
"code": code,
|
|
"price": exchange_rate,
|
|
"min_amount": min_trade * exchange_rate,
|
|
"max_amount": max_trade * exchange_rate,
|
|
}
|
|
|
|
return Response(payload, status.HTTP_200_OK)
|
|
|
|
|
|
class HistoricalView(ListAPIView):
|
|
@extend_schema(**HistoricalViewSchema.get)
|
|
def get(self, request):
|
|
payload = {}
|
|
queryset = AccountingDay.objects.all().order_by("day")
|
|
|
|
for accounting_day in queryset:
|
|
payload[str(accounting_day.day)] = {
|
|
"volume": accounting_day.contracted,
|
|
"num_contracts": accounting_day.num_contracts,
|
|
}
|
|
|
|
return Response(payload, status.HTTP_200_OK)
|
|
|
|
|
|
class StealthView(UpdateAPIView):
|
|
|
|
serializer_class = StealthSerializer
|
|
|
|
@extend_schema(**StealthViewSchema.put)
|
|
def put(self, request):
|
|
serializer = self.serializer_class(data=request.data)
|
|
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{"bad_request": "Woops! It seems you do not have a robot avatar"},
|
|
status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
stealth = serializer.data.get("wantsStealth")
|
|
|
|
request.user.robot.wants_stealth = stealth
|
|
request.user.robot.save()
|
|
|
|
return Response({"wantsStealth": stealth})
|