From 8a55383761c6b71d13119e503966ac634834fec1 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 7 Jan 2022 03:31:33 -0800 Subject: [PATCH] Add more api logics The workflow is actually more complex than I though. In fact the whole scope of the project greatly surpass my expectation of "weekend project". Want to lay down something functional even if buggy and ugly, I'm a bad coder but this will work out! --- api/logics.py | 125 +++++++++++++++++------ api/models.py | 41 ++++---- api/views.py | 144 ++++++++++++++++----------- frontend/src/components/MakerPage.js | 12 ++- frontend/src/components/OrderPage.js | 1 + 5 files changed, 213 insertions(+), 110 deletions(-) diff --git a/api/logics.py b/api/logics.py index f8ebab04..a0f6c62a 100644 --- a/api/logics.py +++ b/api/logics.py @@ -29,18 +29,18 @@ class Logics(): '''Checks if the user is already partipant of an order''' queryset = Order.objects.filter(maker=user) if queryset.exists(): - return False, {'Bad Request':'You are already maker of an order'} + return False, {'bad_request':'You are already maker of an order'} queryset = Order.objects.filter(taker=user) if queryset.exists(): - return False, {'Bad Request':'You are already taker of an order'} + return False, {'bad_request':'You are already taker of an order'} return True, None def validate_order_size(order): '''Checks if order is withing limits at t0''' if order.t0_satoshis > MAX_TRADE: - return False, {'Bad_request': 'Your order is too big. It is worth {order.t0_satoshis} now, max is {MAX_TRADE}'} + return False, {'bad_request': f'Your order is too big. It is worth {order.t0_satoshis} now. But maximum is {MAX_TRADE}'} if order.t0_satoshis < MIN_TRADE: - return False, {'Bad_request': 'Your order is too small. It is worth {order.t0_satoshis} now, min is {MIN_TRADE}'} + return False, {'bad_request': f'Your order is too small. It is worth {order.t0_satoshis} now. But minimum is {MIN_TRADE}'} return True, None def take(order, user): @@ -64,11 +64,12 @@ class Logics(): satoshis_now = order.satoshis else: # TODO Add fallback Public APIs and error handling + # Think about polling price data in a different way (e.g. store locally every t seconds) market_prices = requests.get(MARKET_PRICE_API).json() exchange_rate = float(market_prices[Order.Currencies(order.currency).label]['last']) satoshis_now = ((float(order.amount) * 1+float(order.premium)) / exchange_rate) * 100*1000*1000 - return satoshis_now + return int(satoshis_now) def order_expires(order): order.status = Order.Status.EXP @@ -76,6 +77,16 @@ class Logics(): order.taker = None order.save() + @classmethod + def buyer_invoice_amount(cls, order, user): + ''' Computes buyer invoice amount. Uses order.last_satoshis, + that is the final trade amount set at Taker Bond time''' + + if cls.is_buyer(order, user): + invoice_amount = int(order.last_satoshis * (1-FEE)) # Trading FEE is charged here. + + return True, {'invoice_amount': invoice_amount} + @classmethod def update_invoice(cls, order, user, invoice): is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) @@ -117,12 +128,26 @@ class Logics(): order.save() return True, None - + @classmethod + def rate_counterparty(cls, order, user, rating): + # if maker, rates taker + if order.maker == user: + order.taker.profile.total_ratings = order.taker.profile.total_ratings + 1 + last_ratings = list(order.taker.profile.last_ratings).append(rating) + order.taker.profile.total_ratings = sum(last_ratings) / len(last_ratings) + # if taker, rates maker + if order.taker == user: + order.maker.profile.total_ratings = order.maker.profile.total_ratings + 1 + last_ratings = list(order.maker.profile.last_ratings).append(rating) + order.maker.profile.total_ratings = sum(last_ratings) / len(last_ratings) + + order.save() + return True, None @classmethod def cancel_order(cls, order, user, state): - - # 1) When maker cancels before bond + + # 1) When maker cancels before bond '''The order never shows up on the book and order status becomes "cancelled". That's it.''' if order.status == Order.Status.WFB and order.maker == user: @@ -140,12 +165,12 @@ class Logics(): ''' The order goes back to the book as public. LNPayment "order.taker_bond" is deleted() ''' - # 4) When taker or maker cancel after bond + # 4) When taker or maker cancel after bond (before escrow) '''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 + # 5) When trade collateral has been posted (after escrow) '''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. @@ -154,22 +179,23 @@ class Logics(): else: return False, {'bad_request':'You cannot cancel this order'} - - @classmethod def gen_maker_hodl_invoice(cls, order, user): - # Do not gen and delete if order is more than 5 minutes old + # Do not gen and cancel 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 False, {'bad_request':'Invoice expired. You did not confirm publishing the order in time. Make a new order.'} - # Return the previous invoice if there was one + # Return the previous invoice if there was one and is still unpaid if order.maker_bond: - return True, {'invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis} + if order.maker_bond.status == LNPayment.Status.INVGEN: + return True, {'bond_invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis} + else: + return False, None - order.satoshis_now = cls.satoshis_now(order) - bond_satoshis = int(order.satoshis_now * BOND_SIZE) + order.last_satoshis = cls.satoshis_now(order) + bond_satoshis = int(order.last_satoshis * BOND_SIZE) description = f'RoboSats - Publishing {str(order)} - This bond will return to you if you do not cheat.' # Gen HODL Invoice @@ -188,22 +214,27 @@ class Logics(): expires_at = expires_at) order.save() - return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} + return True, {'bond_invoice':invoice,'bond_satoshis':bond_satoshis} @classmethod def gen_taker_hodl_invoice(cls, order, user): - # Do not gen and cancel if a taker invoice is there and older than 2 minutes + # Do not gen and cancel if a taker invoice is there and older than X minutes and unpaid still if order.taker_bond: - 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.'} + # Check if status is INVGEN and still not expired + if order.taker_bond.status == LNPayment.Status.INVGEN: + 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, {'bad_request':'Invoice expired. You did not confirm taking the order in time.'} + # Return the previous invoice there was with INVGEN status + else: + return True, {'bond_invoice':order.taker_bond.invoice,'bond_satoshis':order.taker_bond.num_satoshis} + # Invoice exists, but was already locked or settled else: - # Return the previous invoice if there was one - return True, {'invoice':order.taker_bond.invoice,'bond_satoshis':order.taker_bond.num_satoshis} + return False, None - order.satoshis_now = cls.satoshis_now(order) - bond_satoshis = int(order.satoshis_now * BOND_SIZE) + order.last_satoshis = cls.satoshis_now(order) # LOCKS THE AMOUNT OF SATOSHIS FOR THE TRADE + bond_satoshis = int(order.last_satoshis * BOND_SIZE) description = f'RoboSats - Taking {str(order)} - This bond will return to you if you do not cheat.' # Gen HODL Invoice @@ -222,4 +253,42 @@ class Logics(): expires_at = expires_at) order.save() - return True, {'invoice':invoice,'bond_satoshis': bond_satoshis} \ No newline at end of file + return True, {'bond_invoice':invoice,'bond_satoshis': bond_satoshis} + + @classmethod + def gen_escrow_hodl_invoice(cls, order, user): + + # Do not generate and cancel if an invoice is there and older than X minutes and unpaid still + if order.trade_escrow: + # Check if status is INVGEN and still not expired + if order.taker_bond.status == LNPayment.Status.INVGEN: + if order.taker_bond.created_at > (timezone.now()+timedelta(minutes=EXP_TRADE_ESCR_INVOICE)): # Expired + cls.cancel_order(order, user, 4) # State 4, cancel order before trade escrow locked + return False, {'bad_request':'Invoice expired. You did not lock the trade escrow in time.'} + # Return the previous invoice there was with INVGEN status + else: + return True, {'escrow_invoice':order.trade_escrow.invoice,'escrow_satoshis':order.trade_escrow.num_satoshis} + # Invoice exists, but was already locked or settled + else: + return False, None # Does not return any context of a healthy locked escrow + + escrow_satoshis = order.last_satoshis # Trade sats amount was fixed at the time of taker bond generation (order.last_satoshis) + description = f'RoboSats - Escrow amount for {str(order)} - This escrow will be released to the buyer once you confirm you received the fiat.' + + # Gen HODL Invoice + invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(escrow_satoshis, description, ESCROW_EXPIRY*3600) + + order.taker_bond = LNPayment.objects.create( + concept = LNPayment.Concepts.TRESCROW, + type = LNPayment.Types.HODL, + sender = user, + receiver = User.objects.get(username=ESCROW_USERNAME), + invoice = invoice, + status = LNPayment.Status.INVGEN, + num_satoshis = escrow_satoshis, + description = description, + payment_hash = payment_hash, + expires_at = expires_at) + + order.save() + return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis} \ No newline at end of file diff --git a/api/models.py b/api/models.py index 1524ef8d..accc8e18 100644 --- a/api/models.py +++ b/api/models.py @@ -27,18 +27,18 @@ class LNPayment(models.Model): class Concepts(models.IntegerChoices): MAKEBOND = 0, 'Maker bond' - TAKEBOND = 1, 'Taker-buyer bond' + TAKEBOND = 1, 'Taker bond' TRESCROW = 2, 'Trade escrow' PAYBUYER = 3, 'Payment to buyer' class Status(models.IntegerChoices): - INVGEN = 0, 'Hodl invoice was generated' - LOCKED = 1, 'Hodl invoice has HTLCs locked' - SETLED = 2, 'Invoice settled' - RETNED = 3, 'Hodl invoice was returned' - MISSNG = 4, 'Buyer invoice is missing' - VALIDI = 5, 'Buyer invoice is valid' - INFAIL = 6, 'Buyer invoice routing failed' + INVGEN = 0, 'Generated' + LOCKED = 1, 'Locked' + SETLED = 2, 'Settled' + RETNED = 3, 'Returned' + MISSNG = 4, 'Missing' + VALIDI = 5, 'Valid' + INFAIL = 6, 'Failed routing' # payment use details type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.HODL) @@ -59,8 +59,7 @@ class LNPayment(models.Model): receiver = models.ForeignKey(User, related_name='receiver', on_delete=models.CASCADE, null=True, default=None) def __str__(self): - # Make relational back to ORDER - return (f'HTLC {self.id}: {self.Concepts(self.concept).label}') + return (f'HTLC {self.id}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}') class Order(models.Model): @@ -74,16 +73,16 @@ class Order(models.Model): ETH = 3, 'ETH' class Status(models.IntegerChoices): - WFB = 0, 'Waiting for maker bond' - PUB = 1, 'Public' - DEL = 2, 'Deleted' - TAK = 3, 'Waiting for taker bond' - UCA = 4, 'Cancelled' - WF2 = 5, 'Waiting for trade collateral and buyer invoice' - WFE = 6, 'Waiting only for seller trade collateral' - WFI = 7, 'Waiting only for buyer invoice' - CHA = 8, 'Sending fiat - In chatroom' - CCA = 9, 'Collaboratively cancelled' + WFB = 0, 'Waiting for maker bond' + PUB = 1, 'Public' + DEL = 2, 'Deleted' + TAK = 3, 'Waiting for taker bond' + UCA = 4, 'Cancelled' + WF2 = 5, 'Waiting for trade collateral and buyer invoice' + WFE = 6, 'Waiting only for seller trade collateral' + WFI = 7, 'Waiting only for buyer invoice' + CHA = 8, 'Sending fiat - In chatroom' + CCA = 9, 'Collaboratively cancelled' FSE = 10, 'Fiat sent - In chatroom' FCO = 11, 'Fiat confirmed' SUC = 12, 'Sucessfully settled' @@ -130,7 +129,7 @@ class Order(models.Model): def __str__(self): # Make relational back to ORDER - return (f'Order {self.id}: {self.Types(self.type).label} {"{:,}".format(self.t0_satoshis)} Sats for {self.Currencies(self.currency).label}') + return (f'Order {self.id}: {self.Types(self.type).label} BTC for {self.amount} {self.Currencies(self.currency).label}') @receiver(pre_delete, sender=Order) def delelete_HTLCs_at_order_deletion(sender, instance, **kwargs): diff --git a/api/views.py b/api/views.py index ea200022..c1cf3897 100644 --- a/api/views.py +++ b/api/views.py @@ -7,7 +7,7 @@ from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import User from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer -from .models import Order +from .models import LNPayment, Order from .logics import Logics from .nick_generator.nick_generator import NickGenerator @@ -74,75 +74,100 @@ class OrderView(viewsets.ViewSet): lookup_url_kwarg = 'order_id' 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 order_id == None: - return Response({'Bad Request':'Order ID parameter not found in request'}, status=status.HTTP_400_BAD_REQUEST) + 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 : - order = order[0] + if len(order) != 1 : + return Response({'bad_request':'Invalid Order Id'}, status.HTTP_404_NOT_FOUND) + + # This is our order. + order = order[0] - # 1) If order expired - if order.status == Order.Status.EXP: - return Response({'bad_request':'This order has expired'},status.HTTP_400_BAD_REQUEST) + # 1) If order expired + if order.status == Order.Status.EXP: + return Response({'bad_request':'This order has expired'},status.HTTP_400_BAD_REQUEST) - # 2) If order 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) + # 2) If order 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 = ListOrderSerializer(order).data - # 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) 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) - - # 4) 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) + # 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) 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':'You are not allowed to see this order'},status.HTTP_403_FORBIDDEN) + + # 4) 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) - # For participants add position side, nicks and status as message - data['is_buyer'] = Logics.is_buyer(order,request.user) - data['is_seller'] = Logics.is_seller(order,request.user) - data['maker_nick'] = str(order.maker) - data['taker_nick'] = str(order.taker) - data['status_message'] = Order.Status(order.status).label + # For participants add position side, nicks and status as message + data['is_buyer'] = Logics.is_buyer(order,request.user) + data['is_seller'] = Logics.is_seller(order,request.user) + data['maker_nick'] = str(order.maker) + data['taker_nick'] = str(order.taker) + data['status_message'] = Order.Status(order.status).label - # 5) If status is 'waiting for maker bond' and user is MAKER, 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: - return Response(context, status.HTTP_400_BAD_REQUEST) - - # 6) If status is 'waiting for taker bond' and user is TAKER, reply with a TAKER HODL invoice. - elif order.status == Order.Status.TAK and data['is_taker']: - valid, context = Logics.gen_taker_hodl_invoice(order, request.user) - if valid: - data = {**data, **context} - else: - return Response(context, status.HTTP_400_BAD_REQUEST) - - # 7) If status is 'WF2'or'WTC' and user is Seller, reply with an ESCROW HODL invoice. - elif (order.status == Order.Status.WF2 or order.status == Order.Status.WFE) and data['is_seller']: - valid, context = Logics.gen_seller_hodl_invoice(order, request.user) - if valid: - data = {**data, **context} - else: - return Response(context, status.HTTP_400_BAD_REQUEST) + # 5) If status is 'waiting for maker bond' and user is MAKER, 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: + return Response(context, status.HTTP_400_BAD_REQUEST) + + # 6) If status is 'waiting for taker bond' and user is TAKER, reply with a TAKER HODL invoice. + elif order.status == Order.Status.TAK and data['is_taker']: + valid, context = Logics.gen_taker_hodl_invoice(order, request.user) + if valid: + data = {**data, **context} + else: + return Response(context, status.HTTP_400_BAD_REQUEST) + + # 7) If status is 'WF2'or'WTC' + elif (order.status == Order.Status.WF2 or order.status == Order.Status.WFE): - return Response(data, status.HTTP_200_OK) - return Response({'Order Not Found':'Invalid Order Id'}, status.HTTP_404_NOT_FOUND) + # If the two bonds are locked + if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED: + + # 7.a) And if user is Seller, reply with an ESCROW HODL invoice. + if data['is_seller']: + valid, context = Logics.gen_escrow_hodl_invoice(order, request.user) + if valid: + data = {**data, **context} + else: + return Response(context, status.HTTP_400_BAD_REQUEST) + + # 7.b) If user is Buyer, reply with an AMOUNT so he can send the buyer invoice. + elif data['is_buyer']: + valid, context = Logics.buyer_invoice_amount(order, request.user) + if valid: + data = {**data, **context} + else: + return Response(context, status.HTTP_400_BAD_REQUEST) + + # 8) If status is 'CHA'or '' or '' and all HTLCS are in LOCKED + elif order.status == Order.Status.CHA: # TODO Add the other status + if order.maker_bond.status == order.taker_bond.status == order.trade_escrow.status == LNPayment.Status.LOCKED: + # add whether a collaborative cancel is pending + data['pending_cancel'] = order.is_pending_cancel + + return Response(data, status.HTTP_200_OK) def take_update_confirm_dispute_cancel(self, request, format=None): ''' @@ -177,7 +202,7 @@ class OrderView(viewsets.ViewSet): # 3) If action is cancel elif action == 'cancel': - valid, context = Logics.cancel_order(order,request.user,invoice) + valid, context = Logics.cancel_order(order,request.user) if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) # 4) If action is confirm @@ -190,11 +215,12 @@ class OrderView(viewsets.ViewSet): # 6) If action is dispute elif action == 'rate' and rating: - pass + valid, context = Logics.rate_counterparty(order,request.user, rating) + if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) # If nothing... something else is going on. Probably not allowed! else: - return Response({'bad_request':'Not allowed'}) + return Response({'bad_request':'The Robotic Satoshis working in the warehouse did not understand you'}) return self.get(request) diff --git a/frontend/src/components/MakerPage.js b/frontend/src/components/MakerPage.js index fa344cbf..a98e5049 100644 --- a/frontend/src/components/MakerPage.js +++ b/frontend/src/components/MakerPage.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { Paper, Button , Grid, Typography, TextField, Select, FormHelperText, MenuItem, FormControl, Radio, FormControlLabel, RadioGroup, Menu} from "@material-ui/core" +import { Paper, Alert, AlertTitle, Button , Grid, Typography, TextField, Select, FormHelperText, MenuItem, FormControl, Radio, FormControlLabel, RadioGroup, Menu} from "@material-ui/core" import { Link } from 'react-router-dom' function getCookie(name) { @@ -104,7 +104,8 @@ export default class MakerPage extends Component { }; fetch("/api/make/",requestOptions) .then((response) => response.json()) - .then((data) => (console.log(data) & this.props.history.push('/order/' + data.id))); + .then((data) => (this.setState({badRequest:data.bad_request}) + & (data.id ? this.props.history.push('/order/' + data.id) :""))); } render() { @@ -242,6 +243,13 @@ export default class MakerPage extends Component { + + + {this.state.badRequest ? + + {this.state.badRequest}
+
+ : ""}
Create a BTC {this.state.type==0 ? "buy":"sell"} order for {this.state.amount} {this.state.currencyCode} diff --git a/frontend/src/components/OrderPage.js b/frontend/src/components/OrderPage.js index 5d3d9d44..86ed50ec 100644 --- a/frontend/src/components/OrderPage.js +++ b/frontend/src/components/OrderPage.js @@ -68,6 +68,7 @@ export default class OrderPage extends Component { isBuyer:data.buyer, isSeller:data.seller, expiresAt:data.expires_at, + badRequest:data.bad_request, }); }); }