From 34e05465c2919b6e7b5a1435da9f77bd1d6f5451 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 12:33:40 -0800 Subject: [PATCH] Add more logics bareframes --- api/admin.py | 2 +- api/lightning.py | 2 +- api/logics.py | 83 ++++++++++----- api/models.py | 36 ++++--- api/serializers.py | 13 +-- api/urls.py | 2 +- api/views.py | 146 ++++++++++++++++----------- frontend/src/components/OrderPage.js | 6 +- 8 files changed, 173 insertions(+), 117 deletions(-) diff --git a/api/admin.py b/api/admin.py index 5e8f2aca..b45ca85b 100644 --- a/api/admin.py +++ b/api/admin.py @@ -30,7 +30,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): @admin.register(LNPayment) class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin): - list_display = ('id','concept','status','num_satoshis','type','invoice','preimage','expires_at','sender_link','receiver_link') + list_display = ('id','concept','status','num_satoshis','type','invoice','expires_at','sender_link','receiver_link') list_display_links = ('id','concept') change_links = ('sender','receiver') diff --git a/api/lightning.py b/api/lightning.py index 6f2b9eae..cf66a12f 100644 --- a/api/lightning.py +++ b/api/lightning.py @@ -13,7 +13,7 @@ class LNNode(): Place holder functions to interact with Lightning Node ''' - def gen_hodl_invoice(num_satoshis, description): + def gen_hodl_invoice(num_satoshis, description, expiry): '''Generates hodl invoice to publish an order''' # TODO invoice = ''.join(random.choices(string.ascii_uppercase + string.digits, k=80)) #FIX diff --git a/api/logics.py b/api/logics.py index 77c87399..5fcdfa95 100644 --- a/api/logics.py +++ b/api/logics.py @@ -11,6 +11,12 @@ BOND_SIZE = float(config('BOND_SIZE')) MARKET_PRICE_API = config('MARKET_PRICE_API') ESCROW_USERNAME = config('ESCROW_USERNAME') +EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE')) +EXP_TAKER_BOND_INVOICE = int(config('EXP_TAKER_BOND_INVOICE')) +EXP_TRADE_ESCR_INVOICE = int(config('EXP_TRADE_ESCR_INVOICE')) + +BOND_EXPIRY = int(config('BOND_EXPIRY')) +ESCROW_EXPIRY = int(config('ESCROW_EXPIRY')) class Logics(): @@ -46,12 +52,10 @@ class Logics(): if order.is_explicit: satoshis_now = order.satoshis else: + # TODO Add fallback Public APIs and error handling market_prices = requests.get(MARKET_PRICE_API).json() - print(market_prices) exchange_rate = float(market_prices[Order.Currencies(order.currency).label]['last']) - print(exchange_rate) satoshis_now = ((float(order.amount) * 1+float(order.premium)) / exchange_rate) * 100*1000*1000 - print(satoshis_now) return satoshis_now @@ -85,25 +89,57 @@ class Logics(): if order.status == Order.Status.FAI: order.status = Order.Status.UPI order.save() - return True + return True, None + + return False, {'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'} + + @classmethod + def cancel_order(cls, order, user, state): + + # 1) When maker cancels before bond + '''The order never shows up on the book and status + changes to cancelled. That's it.''' + + # 2) When maker cancels after bond + '''The order dissapears from book and goes to cancelled. + Maker is charged a small amount of sats, to prevent DDOS + on the LN node and order book''' + + # 3) When taker cancels before bond + ''' The order goes back to the book as public. + LNPayment "order.taker_bond" is deleted() ''' + + # 4) When taker or maker cancel after bond + '''The order goes into cancelled status if maker cancels. + The order goes into the public book if taker cancels. + In both cases there is a small fee.''' + + # 5) When trade collateral has been posted + '''Always goes to cancelled status. Collaboration is needed. + When a user asks for cancel, 'order.is_pending_cancel' goes True. + When the second user asks for cancel. Order is totally cancelled. + Has a small cost for both parties to prevent node DDOS.''' + pass - return False @classmethod def gen_maker_hodl_invoice(cls, order, user): - # Do not and delete if order is more than 5 minutes old + # Do not gen and delete if order is more than 5 minutes old if order.expires_at < timezone.now(): cls.order_expires(order) return False, {'Order expired':'cannot generate a bond invoice for an expired order. Make a new one.'} + # Return the previous invoice if there was one if order.maker_bond: return True, {'invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis} order.satoshis_now = cls.satoshis_now(order) bond_satoshis = order.satoshis_now * BOND_SIZE - description = f'Robosats maker bond for order ID {order.id}. Will return to you if you do not cheat!' - invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(num_satoshis = bond_satoshis, description=description) + description = f'RoboSats - Maker bond for order ID {order.id}. These sats will return to you if you do not cheat!' + + # Gen HODL Invoice + invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600) order.maker_bond = LNPayment.objects.create( concept = LNPayment.Concepts.MAKEBOND, @@ -115,30 +151,32 @@ class Logics(): num_satoshis = bond_satoshis, description = description, payment_hash = payment_hash, - expires_at = expires_at, - ) + expires_at = expires_at) order.save() return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} @classmethod - def gen_taker_buyer_hodl_invoice(cls, order, user): + def gen_takerbuyer_hodl_invoice(cls, order, user): - # Do not and delete if order is more than 5 minutes old - if order.expires_at < timezone.now(): - cls.order_expires(order) - return False, {'Order expired':'cannot generate a bond invoice for an expired order. Make a new one.'} + # Do not gen and cancel if a taker invoice is there and older than 2 minutes + if order.taker_bond.created_at < (timezone.now()+timedelta(minutes=EXP_TAKER_BOND_INVOICE)): + cls.cancel_order(order, user, 3) # State 3, cancel order before taker bond + return False, {'Invoice expired':'You did not confirm taking the order in time.'} - if order.maker_bond: - return True, {'invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis} + # Return the previous invoice if there was one + if order.taker_bond: + return True, {'invoice':order.taker_bond.invoice,'bond_satoshis':order.taker_bond.num_satoshis} order.satoshis_now = cls.satoshis_now(order) bond_satoshis = order.satoshis_now * BOND_SIZE - description = f'Robosats maker bond for order ID {order.id}. Will return to you if you do not cheat!' - invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(num_satoshis = bond_satoshis, description=description) + description = f'RoboSats - Taker bond for order ID {order.id}. These sats will return to you if you do not cheat!' + + # Gen HODL Invoice + invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600) - order.maker_bond = LNPayment.objects.create( - concept = LNPayment.Concepts.MAKEBOND, + order.taker_bond = LNPayment.objects.create( + concept = LNPayment.Concepts.TAKEBOND, type = LNPayment.Types.HODL, sender = user, receiver = User.objects.get(username=ESCROW_USERNAME), @@ -147,8 +185,7 @@ class Logics(): num_satoshis = bond_satoshis, description = description, payment_hash = payment_hash, - expires_at = expires_at, - ) + expires_at = expires_at) order.save() return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} \ No newline at end of file diff --git a/api/models.py b/api/models.py index 655189cc..2c2ad4f7 100644 --- a/api/models.py +++ b/api/models.py @@ -49,7 +49,6 @@ class LNPayment(models.Model): # payment info invoice = models.CharField(max_length=300, unique=False, null=True, default=None, blank=True) payment_hash = models.CharField(max_length=300, unique=False, null=True, default=None, blank=True) - preimage = models.CharField(max_length=300, unique=False, null=True, default=None, blank=True) description = models.CharField(max_length=300, unique=False, null=True, default=None, blank=True) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField() @@ -79,22 +78,21 @@ class Order(models.Model): PUB = 1, 'Public' DEL = 2, 'Deleted' TAK = 3, 'Waiting for taker bond' # only needed when taker is a buyer - UCA = 4, 'Unilaterally cancelled' - RET = 5, 'Returned to order book' # Probably same as 1 in most cases. - WF2 = 6, 'Waiting for trade collateral and buyer invoice' - WTC = 7, 'Waiting only for trade collateral' - WBI = 8, 'Waiting only for buyer invoice' - EXF = 9, 'Exchanging fiat / In chat' - CCA = 10, 'Collaboratively cancelled' - FSE = 11, 'Fiat sent' - FCO = 12, 'Fiat confirmed' - SUC = 13, 'Sucessfully settled' - FAI = 14, 'Failed lightning network routing' - UPI = 15, 'Updated invoice' - DIS = 16, 'In dispute' - MLD = 17, 'Maker lost dispute' - TLD = 18, 'Taker lost dispute' - EXP = 19, 'Expired' + UCA = 4, 'Cancelled' + WF2 = 5, 'Waiting for trade collateral and buyer invoice' + WTC = 6, 'Waiting only for seller trade collateral' + WBI = 7, 'Waiting only for buyer invoice' + EXF = 8, 'Sending fiat - In chatroom' + CCA = 9, 'Collaboratively cancelled' + FSE = 10, 'Fiat sent - In chatroom' + FCO = 11, 'Fiat confirmed' + SUC = 12, 'Sucessfully settled' + FAI = 13, 'Failed lightning network routing' + UPI = 14, 'Updated invoice' + DIS = 15, 'In dispute' + MLD = 16, 'Maker lost dispute' + TLD = 17, 'Taker lost dispute' + EXP = 18, 'Expired' # order info status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.WFB) @@ -117,11 +115,11 @@ class Order(models.Model): 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... - # order participants maker = models.ForeignKey(User, related_name='maker', on_delete=models.CASCADE, 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 - + is_pending_cancel = models.BooleanField(default=False, null=False) # When collaborative cancel is needed and one partner has cancelled. + # order collateral maker_bond = models.ForeignKey(LNPayment, related_name='maker_bond', on_delete=models.SET_NULL, null=True, default=None, blank=True) taker_bond = models.ForeignKey(LNPayment, related_name='taker_bond', on_delete=models.SET_NULL, null=True, default=None, blank=True) diff --git a/api/serializers.py b/api/serializers.py index 7298b77d..d77b1e17 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -11,12 +11,7 @@ class MakeOrderSerializer(serializers.ModelSerializer): model = Order fields = ('type','currency','amount','payment_method','is_explicit','premium','satoshis') -class UpdateOrderSerializer(serializers.ModelSerializer): - class Meta: - model = Order - fields = ('id','buyer_invoice') - -class UpdateInvoiceSerializer(serializers.ModelSerializer): - class Meta: - model = LNPayment - fields = ['invoice'] \ No newline at end of file +class UpdateOrderSerializer(serializers.Serializer): + invoice = serializers.CharField(max_length=300, allow_null=True, allow_blank=True, default=None) + action = serializers.ChoiceField(choices=('take','dispute','cancel','confirm','rate'), allow_null=False) + rating = serializers.ChoiceField(choices=('1','2','3','4','5'), allow_null=True, allow_blank=True, default=None) \ No newline at end of file diff --git a/api/urls.py b/api/urls.py index eae708dd..b10893c8 100644 --- a/api/urls.py +++ b/api/urls.py @@ -3,7 +3,7 @@ from .views import OrderMakerView, OrderView, UserView, BookView urlpatterns = [ path('make/', OrderMakerView.as_view()), - path('order/', OrderView.as_view({'get':'get','post':'take_or_update'})), + path('order/', OrderView.as_view({'get':'get','post':'take_update_confirm_dispute_cancel'})), path('usergen/', UserView.as_view()), path('book/', BookView.as_view()), ] \ No newline at end of file diff --git a/api/views.py b/api/views.py index 68a09041..1b8900a1 100644 --- a/api/views.py +++ b/api/views.py @@ -7,9 +7,9 @@ from rest_framework.response import Response from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import User -from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateInvoiceSerializer +from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer from .models import Order -from .logics import Logics +from .logics import EXP_MAKER_BOND_INVOICE, Logics from .nick_generator.nick_generator import NickGenerator from robohash import Robohash @@ -23,7 +23,7 @@ from django.utils import timezone from decouple import config -EXPIRATION_MAKE = config('EXPIRATION_MAKE') +EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE')) avatar_path = Path('frontend/static/assets/avatars') avatar_path.mkdir(parents=True, exist_ok=True) @@ -36,44 +36,39 @@ class OrderMakerView(CreateAPIView): def post(self,request): serializer = self.serializer_class(data=request.data) - if serializer.is_valid(): - otype = serializer.data.get('type') - currency = serializer.data.get('currency') - amount = serializer.data.get('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') + if not serializer.is_valid(): return Response(status=status.HTTP_400_BAD_REQUEST) - valid, context = Logics.validate_already_maker_or_taker(request.user) - if not valid: - return Response(context, status=status.HTTP_409_CONFLICT) + type = serializer.data.get('type') + currency = serializer.data.get('currency') + amount = serializer.data.get('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') - # Creates a new order in db - order = Order( - type=otype, - status=Order.Status.WFB, - currency=currency, - amount=amount, - payment_method=payment_method, - premium=premium, - satoshis=satoshis, - is_explicit=is_explicit, - expires_at=timezone.now()+timedelta(minutes=EXPIRATION_MAKE), - maker=request.user) + valid, context = Logics.validate_already_maker_or_taker(request.user) + if not valid: return Response(context, status=status.HTTP_409_CONFLICT) - order.t0_satoshis=Logics.satoshis_now(order) # TODO reate Order class method when new instance is created! - order.last_satoshis=Logics.satoshis_now(order) - order.save() + # Creates a new order + order = Order( + type=type, + currency=currency, + amount=amount, + payment_method=payment_method, + premium=premium, + satoshis=satoshis, + is_explicit=is_explicit, + expires_at=timezone.now()+timedelta(minutes=EXP_MAKER_BOND_INVOICE), + maker=request.user) - if not serializer.is_valid(): - return Response(status=status.HTTP_400_BAD_REQUEST) - + order.last_satoshis = order.t0_satoshis = Logics.satoshis_now(order) # TODO move to Order class method when new instance is created! + + order.save() return Response(ListOrderSerializer(order).data, status=status.HTTP_201_CREATED) class OrderView(viewsets.ViewSet): - serializer_class = UpdateInvoiceSerializer + serializer_class = UpdateOrderSerializer lookup_url_kwarg = 'order_id' def get(self, request, format=None): @@ -88,7 +83,7 @@ class OrderView(viewsets.ViewSet): if len(order) == 1 : order = order[0] - # If order expired + # 1) If order expired if order.status == Order.Status.EXP: return Response({'bad_request':'This order has expired'},status.HTTP_400_BAD_REQUEST) @@ -99,11 +94,11 @@ class OrderView(viewsets.ViewSet): data['is_taker'] = order.taker == request.user data['is_participant'] = data['is_maker'] or data['is_taker'] - # If not a participant and order is not public, forbid. + # 2) 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':'Not allowed to see this order'},status.HTTP_403_FORBIDDEN) - # non participants can view some details, but only if PUB + # 3) Non participants can view details (but only if PUB) elif not data['is_participant'] and order.status != Order.Status.PUB: return Response(data, status=status.HTTP_200_OK) @@ -114,45 +109,73 @@ class OrderView(viewsets.ViewSet): data['taker_nick'] = str(order.taker) data['status_message'] = Order.Status(order.status).label - # If status is 'waiting for maker bond', reply with a hodl invoice too. + # 4) If status is 'waiting for maker bond', reply with a MAKER HODL invoice. if order.status == Order.Status.WFB and data['is_maker']: valid, context = Logics.gen_maker_hodl_invoice(order, request.user) - if valid: - data = {**data, **context} - else: - Response(context, status=status.HTTP_400_BAD_REQUEST) + data = {**data, **context} if valid else Response(context, status.HTTP_400_BAD_REQUEST) + + # 5) If status is 'Public' and user is taker/buyer, reply with a TAKER HODL invoice. + elif order.status == Order.Status.PUB and data['is_taker'] and data['is_buyer']: + valid, context = Logics.gen_takerbuyer_hodl_invoice(order, request.user) + data = {**data, **context} if valid else Response(context, status.HTTP_400_BAD_REQUEST) + + # 6) If status is 'Public' and user is taker/seller, reply with a ESCROW HODL invoice. + elif order.status == Order.Status.PUB and data['is_taker'] and data['is_seller']: + valid, context = Logics.gen_seller_hodl_invoice(order, request.user) + data = {**data, **context} if valid else Response(context, status.HTTP_400_BAD_REQUEST) + + # 7) If status is 'WF2/WTC' and user is maker/seller, reply with an ESCROW HODL invoice. + elif (order.status == Order.Status.WF2 or order.status == Order.Status.WF2) and data['is_maker'] and data['is_seller']: + valid, context = Logics.gen_seller_hodl_invoice(order, request.user) + data = {**data, **context} if valid else Response(context, status=status.HTTP_400_BAD_REQUEST) return Response(data, status=status.HTTP_200_OK) - return Response({'Order Not Found':'Invalid Order Id'},status=status.HTTP_404_NOT_FOUND) - - - def take_or_update(self, request, format=None): + def take_update_confirm_dispute_cancel(self, request, format=None): order_id = request.GET.get(self.lookup_url_kwarg) - serializer = UpdateInvoiceSerializer(data=request.data) + 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) - if serializer.is_valid(): - invoice = serializer.data.get('invoice') + # action is either 1)'take', 2)'confirm', 3)'cancel', 4)'dispute' , 5)'update' (invoice) 6)'rate' (counterparty) + action = serializer.data.get('action') + invoice = serializer.data.get('invoice') + rating = serializer.data.get('rating') + # 1) If action is take, it is be 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) + + Logics.take(order, request.user) + else: Response({'bad_request':'This order is not public anymore.'}, status.HTTP_400_BAD_REQUEST) + + # 2) If action is update (invoice) + elif action == 'update' and invoice: + updated, context = Logics.update_invoice(order,request.user,invoice) + if not updated: return Response(context,status.HTTP_400_BAD_REQUEST) - # If this is an empty POST request (no invoice), it must be taker request! - if not invoice and 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) + # 3) If action is cancel + elif action == 'cancel': + pass - Logics.take(order, request.user) + # 4) If action is confirm + elif action == 'confirm': + pass - # An invoice came in! update it - elif invoice: - print(invoice) - updated = Logics.update_invoice(order=order,user=request.user,invoice=invoice) - if not updated: - return Response({'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'}) - - # Something else is going on. Probably not allowed. + # 5) If action is dispute + elif action == 'dispute': + pass + + # 6) If action is dispute + elif action == 'rate' and rating: + pass + + # If nothing... something else is going on. Probably not allowed! else: return Response({'bad_request':'Not allowed'}) @@ -264,6 +287,7 @@ class BookView(ListAPIView): user = User.objects.filter(id=data['maker']) if len(user) == 1: data['maker_nick'] = user[0].username + # Non participants should not see the status or who is the taker for key in ('status','taker'): del data[key] diff --git a/frontend/src/components/OrderPage.js b/frontend/src/components/OrderPage.js index 6deef6bf..5d3d9d44 100644 --- a/frontend/src/components/OrderPage.js +++ b/frontend/src/components/OrderPage.js @@ -87,8 +87,10 @@ export default class OrderPage extends Component { console.log(this.state) const requestOptions = { method: 'POST', - headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')}, - body: JSON.stringify({}), + headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),}, + body: JSON.stringify({ + 'action':'take', + }), }; fetch('/api/order/' + '?order_id=' + this.orderId, requestOptions) .then((response) => response.json())