From 46c129bf8098116998650ea41deb59a014d87641 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 04:32:17 -0800 Subject: [PATCH 01/12] Add logics module --- api/admin.py | 2 +- api/lightning.py | 14 ++++- api/models.py | 82 ++++++++++++++++++++++---- api/serializers.py | 9 ++- api/views.py | 56 +++++------------- frontend/src/components/BookPage.js | 16 ++--- frontend/src/components/UserGenPage.js | 2 +- 7 files changed, 113 insertions(+), 68 deletions(-) diff --git a/api/admin.py b/api/admin.py index 1d0bd415..f496caa8 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','amount','type','invoice','secret','expires_at','sender_link','receiver_link') + list_display = ('id','concept','status','num_satoshis','type','invoice','preimage','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 50d60529..de480279 100644 --- a/api/lightning.py +++ b/api/lightning.py @@ -1,3 +1,5 @@ +from django.utils import timezone + import random import string @@ -18,9 +20,15 @@ class LNNode(): '''Generates hodl invoice to publish an order''' return True - def validate_ln_invoice(invoice): - '''Checks if a LN invoice is valid''' - return True + def validate_ln_invoice(invoice): # num_satoshis + '''Checks if the submited LN invoice is as expected''' + valid = True + num_satoshis = 50000 # TODO decrypt and confirm sats are as expected + description = 'Placeholder desc' # TODO decrypt from LN invoice + payment_hash = '567126' # TODO decrypt + expires_at = timezone.now() # TODO decrypt + + return valid, num_satoshis, description, payment_hash, expires_at def pay_buyer_invoice(invoice): '''Sends sats to buyer''' diff --git a/api/models.py b/api/models.py index 452e57e3..b5cb5135 100644 --- a/api/models.py +++ b/api/models.py @@ -3,11 +3,12 @@ from django.contrib.auth.models import User from django.core.validators import MaxValueValidator, MinValueValidator, validate_comma_separated_integer_list from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver - from django.utils.html import mark_safe from pathlib import Path +from .lightning import LNNode + ############################# # TODO # Load hparams from .env file @@ -16,7 +17,7 @@ MIN_TRADE = 10*1000 #In sats MAX_TRADE = 500*1000 FEE = 0.002 # Trade fee in % BOND_SIZE = 0.01 # Bond in % - +ESCROW_USERNAME = 'admin' class LNPayment(models.Model): @@ -33,25 +34,28 @@ class LNPayment(models.Model): class Status(models.IntegerChoices): INVGEN = 0, 'Hodl invoice was generated' LOCKED = 1, 'Hodl invoice has HTLCs locked' - CHRGED = 2, 'Hodl invoice was charged' + SETLED = 2, 'Invoice settled' RETNED = 3, 'Hodl invoice was returned' MISSNG = 4, 'Buyer invoice is missing' - IVALID = 5, 'Buyer invoice is valid' - INPAID = 6, 'Buyer invoice was paid' - INFAIL = 7, 'Buyer invoice routing failed' + VALIDI = 5, 'Buyer invoice is valid' + INFAIL = 6, 'Buyer invoice routing failed' - # payment use case + # payment use details type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.HODL) concept = models.PositiveSmallIntegerField(choices=Concepts.choices, null=False, default=Concepts.MAKEBOND) status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.INVGEN) + routing_retries = models.PositiveSmallIntegerField(null=False, default=0) - # payment details + # payment info invoice = models.CharField(max_length=300, unique=False, null=True, default=None, blank=True) - secret = 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() - amount = models.PositiveBigIntegerField(validators=[MinValueValidator(MIN_TRADE*BOND_SIZE), MaxValueValidator(MAX_TRADE*(1+BOND_SIZE+FEE))]) + num_satoshis = models.PositiveBigIntegerField(validators=[MinValueValidator(MIN_TRADE*BOND_SIZE), MaxValueValidator(MAX_TRADE*(1+BOND_SIZE+FEE))]) - # payment relationals + # involved parties sender = models.ForeignKey(User, related_name='sender', on_delete=models.CASCADE, null=True, default=None) receiver = models.ForeignKey(User, related_name='receiver', on_delete=models.CASCADE, null=True, default=None) @@ -123,7 +127,6 @@ class Order(models.Model): # buyer payment LN invoice buyer_invoice = models.ForeignKey(LNPayment, related_name='buyer_invoice', on_delete=models.SET_NULL, null=True, default=None, blank=True) - class Profile(models.Model): user = models.OneToOneField(User,on_delete=models.CASCADE) @@ -166,3 +169,58 @@ class Profile(models.Model): # method to create a fake table field in read only mode def avatar_tag(self): return mark_safe('' % self.get_avatar()) + +class Logics(): + + def validate_already_maker_or_taker(user): + '''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'} + queryset = Order.objects.filter(taker=user) + if queryset.exists(): + return False, {'Bad Request':'You are already taker of an order'} + return True, None + + def take(order, user): + order.taker = user + order.status = Order.Status.TAK + order.save() + + def is_buyer(order, user): + is_maker = order.maker == user + is_taker = order.taker == user + return (is_maker and order.type == Order.Types.BUY) or (is_taker and order.type == Order.Types.SELL) + + def is_seller(order, user): + is_maker = order.maker == user + is_taker = order.taker == user + return (is_maker and order.type == Order.Types.SELL) or (is_taker and order.type == Order.Types.BUY) + + @classmethod + def update_invoice(cls, order, user, invoice): + is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) + # only user is the buyer and a valid LN invoice + if cls.is_buyer(order, user) and is_valid_invoice: + order.buyer_invoice, created = LNPayment.objects.update_or_create( + receiver= user, + concept = LNPayment.Concepts.PAYBUYER, + type = LNPayment.Types.NORM, + sender = User.objects.get(username=ESCROW_USERNAME), + # if there is a LNPayment matching these above, it updates that with defaults below. + defaults={ + 'invoice' : invoice, + 'status' : LNPayment.Status.VALIDI, + 'num_satoshis' : num_satoshis, + 'description' : description, + 'payment_hash' : payment_hash, + 'expires_at' : expires_at} + ) + + #If the order status was Payment Failed. Move foward to invoice Updated. + if order.status == Order.Status.FAI: + order.status = Order.Status.UPI + order.save() + return True + + return False diff --git a/api/serializers.py b/api/serializers.py index c88b14b8..7298b77d 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Order +from .models import Order, LNPayment class ListOrderSerializer(serializers.ModelSerializer): class Meta: @@ -14,4 +14,9 @@ class MakeOrderSerializer(serializers.ModelSerializer): class UpdateOrderSerializer(serializers.ModelSerializer): class Meta: model = Order - fields = ('id','buyer_invoice') \ No newline at end of file + fields = ('id','buyer_invoice') + +class UpdateInvoiceSerializer(serializers.ModelSerializer): + class Meta: + model = LNPayment + fields = ['invoice'] \ No newline at end of file diff --git a/api/views.py b/api/views.py index 04fc6d42..1a7eaadd 100644 --- a/api/views.py +++ b/api/views.py @@ -7,8 +7,8 @@ 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, UpdateOrderSerializer -from .models import Order, LNPayment +from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateInvoiceSerializer +from .models import Order, LNPayment, Logics from .lightning import LNNode from .nick_generator.nick_generator import NickGenerator @@ -27,19 +27,6 @@ expiration_time = 8 avatar_path = Path('frontend/static/assets/avatars') avatar_path.mkdir(parents=True, exist_ok=True) -def validate_already_maker_or_taker(request): - '''Checks if the user is already partipant of an order''' - - queryset = Order.objects.filter(maker=request.user.id) - if queryset.exists(): - return False, Response({'Bad Request':'You are already maker of an order'}, status=status.HTTP_400_BAD_REQUEST) - - queryset = Order.objects.filter(taker=request.user.id) - if queryset.exists(): - return False, Response({'Bad Request':'You are already taker of an order'}, status=status.HTTP_400_BAD_REQUEST) - - return True, None - # Create your views here. class OrderMakerView(CreateAPIView): @@ -57,9 +44,9 @@ class OrderMakerView(CreateAPIView): satoshis = serializer.data.get('satoshis') is_explicit = serializer.data.get('is_explicit') - valid, response = validate_already_maker_or_taker(request) + valid, context = Logics.validate_already_maker_or_taker(request.user) if not valid: - return response + return Response(context, status=status.HTTP_409_CONFLICT) # Creates a new order in db order = Order( @@ -82,7 +69,7 @@ class OrderMakerView(CreateAPIView): class OrderView(viewsets.ViewSet): - serializer_class = UpdateOrderSerializer + serializer_class = UpdateInvoiceSerializer lookup_url_kwarg = 'order_id' def get(self, request, format=None): @@ -129,44 +116,31 @@ class OrderView(viewsets.ViewSet): def take_or_update(self, request, format=None): order_id = request.GET.get(self.lookup_url_kwarg) - serializer = UpdateOrderSerializer(data=request.data) + serializer = UpdateInvoiceSerializer(data=request.data) order = Order.objects.get(id=order_id) if serializer.is_valid(): - invoice = serializer.data.get('buyer_invoice') + invoice = serializer.data.get('invoice') + # If this is an empty POST request (no invoice), it must be taker request! - if not invoice and order.status == Order.Status.PUB: - - valid, response = validate_already_maker_or_taker(request) - if not valid: - return response + 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) - order.taker = self.request.user - order.status = Order.Status.TAK - - #TODO REPLY WITH HODL INVOICE - data = ListOrderSerializer(order).data + Logics.take(order, request.user) # An invoice came in! update it elif invoice: - if LNNode.validate_ln_invoice(invoice): - order.invoice = invoice - - #TODO Validate if request comes from PARTICIPANT AND BUYER - - #If the order status was Payment Failed. Move foward to invoice Updated. - if order.status == Order.Status.FAI: - order.status = Order.Status.UPI - - else: + 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. else: return Response({'bad_request':'Not allowed'}) - order.save() return self.get(request) class UserView(APIView): diff --git a/frontend/src/components/BookPage.js b/frontend/src/components/BookPage.js index 93cb63d8..bdf30d83 100644 --- a/frontend/src/components/BookPage.js +++ b/frontend/src/components/BookPage.js @@ -1,5 +1,6 @@ import React, { Component } from "react"; -import { Button , Divider, Card, CardActionArea, CardContent, Typography, Grid, Select, MenuItem, FormControl, FormHelperText, List, ListItem, ListItemText, Avatar, Link, RouterLink, ListItemAvatar} from "@material-ui/core" +import { Button , Divider, Card, CardActionArea, CardContent, Typography, Grid, Select, MenuItem, FormControl, FormHelperText, List, ListItem, ListItemText, Avatar, RouterLink, ListItemAvatar} from "@material-ui/core" +import { Link } from 'react-router-dom' export default class BookPage extends Component { constructor(props) { @@ -13,7 +14,6 @@ export default class BookPage extends Component { this.state.currencyCode = this.getCurrencyCode(this.state.currency) } - // Fix needed to handle HTTP 404 error when no order is found // Show message to be the first one to make an order getOrderDetails() { fetch('/api/book' + '?currency=' + this.state.currency + "&type=" + this.state.type) @@ -90,14 +90,14 @@ export default class BookPage extends Component { ◑ Payment via {order.payment_method} - +{/* ◑ Priced {order.is_explicit ? " explicitly at " + this.pn(order.satoshis) + " Sats" : ( " at " + parseFloat(parseFloat(order.premium).toFixed(4)) + "% over the market" )} - + */} {" 42,354 "}{this.getCurrencyCode(order.currency)}/BTC (Binance API) @@ -176,13 +176,13 @@ export default class BookPage extends Component { No orders found to {this.state.type == 0 ? ' sell ' :' buy ' } BTC for {this.state.currencyCode} + + + + Be the first one to create an order - - - - ) : this.bookCards() } diff --git a/frontend/src/components/UserGenPage.js b/frontend/src/components/UserGenPage.js index 0332043b..ef7d9c98 100644 --- a/frontend/src/components/UserGenPage.js +++ b/frontend/src/components/UserGenPage.js @@ -58,7 +58,7 @@ export default class UserGenPage extends Component { delGeneratedUser() { const requestOptions = { method: 'DELETE', - headers: {'Content-Type':'application/json', 'X-CSRFToken': csrftoken}, + headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')}, }; fetch("/api/usergen", requestOptions) .then((response) => response.json()) From 5505476ea4b6376ee6465ca05c54dbb971323f0a Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 05:55:47 -0800 Subject: [PATCH 02/12] Add logics for Invoice update/creation and maker_hodl_invoice --- api/lightning.py | 10 +++++-- api/models.py | 70 ++++++++++++++++++++++++++++++++++++++------ api/views.py | 75 ++++++++++++++++++++++++++---------------------- 3 files changed, 111 insertions(+), 44 deletions(-) diff --git a/api/lightning.py b/api/lightning.py index de480279..6f2b9eae 100644 --- a/api/lightning.py +++ b/api/lightning.py @@ -1,3 +1,4 @@ +from datetime import timedelta from django.utils import timezone import random @@ -12,9 +13,14 @@ class LNNode(): Place holder functions to interact with Lightning Node ''' - def gen_hodl_invoice(): + def gen_hodl_invoice(num_satoshis, description): '''Generates hodl invoice to publish an order''' - return ''.join(random.choices(string.ascii_uppercase + string.digits, k=80)) + # TODO + invoice = ''.join(random.choices(string.ascii_uppercase + string.digits, k=80)) #FIX + payment_hash = ''.join(random.choices(string.ascii_uppercase + string.digits, k=40)) #FIX + expires_at = timezone.now() + timedelta(hours=8) ##FIX + + return invoice, payment_hash, expires_at def validate_hodl_invoice_locked(): '''Generates hodl invoice to publish an order''' diff --git a/api/models.py b/api/models.py index b5cb5135..44a2f7ab 100644 --- a/api/models.py +++ b/api/models.py @@ -5,6 +5,9 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from django.utils.html import mark_safe +from datetime import timedelta +from django.utils import timezone + from pathlib import Path from .lightning import LNNode @@ -75,10 +78,10 @@ class Order(models.Model): ETH = 3, 'ETH' class Status(models.IntegerChoices): - WFB = 0, 'Waiting for bond' - PUB = 1, 'Published in order book' - DEL = 2, 'Deleted from order book' - TAK = 3, 'Taken' + WFB = 0, 'Waiting for maker bond' + 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' @@ -109,11 +112,14 @@ class Order(models.Model): # 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 marked + # marked to market premium = models.DecimalField(max_digits=5, decimal_places=2, default=0, null=True, validators=[MinValueValidator(-100), MaxValueValidator(999)], blank=True) - t0_market_satoshis = models.PositiveBigIntegerField(null=True, validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], blank=True) # explicit satoshis = models.PositiveBigIntegerField(null=True, validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], blank=True) + # 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... + # 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 @@ -172,6 +178,8 @@ class Profile(models.Model): class Logics(): + # escrow_user = User.objects.get(username=ESCROW_USERNAME) + def validate_already_maker_or_taker(user): '''Checks if the user is already partipant of an order''' queryset = Order.objects.filter(maker=user) @@ -197,16 +205,30 @@ class Logics(): is_taker = order.taker == user return (is_maker and order.type == Order.Types.SELL) or (is_taker and order.type == Order.Types.BUY) + def satoshis_now(order): + ''' checks trade amount in sats ''' + # TODO + # order.last_satoshis = + # order.save() + + return 50000 + + def order_expires(order): + order.status = Order.Status.EXP + order.maker = None + order.taker = None + order.save() + @classmethod def update_invoice(cls, order, user, invoice): is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) # only user is the buyer and a valid LN invoice if cls.is_buyer(order, user) and is_valid_invoice: - order.buyer_invoice, created = LNPayment.objects.update_or_create( - receiver= user, + order.buyer_invoice, _ = LNPayment.objects.update_or_create( concept = LNPayment.Concepts.PAYBUYER, type = LNPayment.Types.NORM, sender = User.objects.get(username=ESCROW_USERNAME), + receiver= user, # if there is a LNPayment matching these above, it updates that with defaults below. defaults={ 'invoice' : invoice, @@ -224,3 +246,35 @@ class Logics(): return True return False + + @classmethod + def gen_maker_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.'} + + if order.maker_bond: + return order.maker_bond.invoice + + bond_amount = cls.satoshis_now(order) + 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_amount, description=description) + + order.maker_bond = LNPayment.objects.create( + concept = LNPayment.Concepts.MAKEBOND, + type = LNPayment.Types.HODL, + sender = user, + receiver = User.objects.get(username=ESCROW_USERNAME), + invoice = invoice, + status = LNPayment.Status.INVGEN, + num_satoshis = bond_amount, + description = description, + payment_hash = payment_hash, + expires_at = expires_at, + ) + + order.save() + return invoice + diff --git a/api/views.py b/api/views.py index 1a7eaadd..0ea88ee4 100644 --- a/api/views.py +++ b/api/views.py @@ -22,7 +22,7 @@ from datetime import timedelta from django.utils import timezone # .env -expiration_time = 8 +EXPIRATION_MAKE = 5 # minutes avatar_path = Path('frontend/static/assets/avatars') avatar_path.mkdir(parents=True, exist_ok=True) @@ -51,14 +51,14 @@ class OrderMakerView(CreateAPIView): # Creates a new order in db order = Order( type=otype, - status=Order.Status.PUB, # TODO orders are public by default for the moment. Future it will be WFB (waiting for bond) + 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(hours=expiration_time), + expires_at= timezone.now()+timedelta(minutes=EXPIRATION_MAKE), maker=request.user) order.save() @@ -75,42 +75,49 @@ class OrderView(viewsets.ViewSet): def get(self, request, format=None): order_id = request.GET.get(self.lookup_url_kwarg) - if order_id != None: - order = Order.objects.filter(id=order_id) + if order_id == 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 : - order = order[0] - data = ListOrderSerializer(order).data - nickname = request.user.username + # check if exactly one order is found in the db + if len(order) == 1 : + order = order[0] - # Add booleans if user is maker, taker, partipant, buyer or seller - data['is_maker'] = str(order.maker) == nickname - data['is_taker'] = str(order.taker) == nickname - data['is_participant'] = data['is_maker'] or data['is_taker'] - data['is_buyer'] = (data['is_maker'] and order.type == Order.Types.BUY) or (data['is_taker'] and order.type == Order.Types.SELL) - data['is_seller'] = (data['is_maker'] and order.type == Order.Types.SELL) or (data['is_taker'] and order.type == Order.Types.BUY) - - # 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) + # If order expired + if order.status == Order.Status.EXP: + return Response({'bad_request':'This order has expired'},status.HTTP_400_BAD_REQUEST) - # return nicks too - data['maker_nick'] = str(order.maker) - data['taker_nick'] = str(order.taker) - - data['status_message'] = Order.Status(order.status).label + data = ListOrderSerializer(order).data - if data['is_participant']: - return Response(data, status=status.HTTP_200_OK) - else: - # Non participants should not see the status, who is the taker, etc - for key in ('status','status_message','taker','taker_nick','is_maker','is_taker','is_buyer','is_seller'): - del data[key] - 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'] + + # 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 + elif not data['is_participant'] and order.status != Order.Status.PUB: + return Response(data, status=status.HTTP_200_OK) - return Response({'Order Not Found':'Invalid Order Id'},status=status.HTTP_404_NOT_FOUND) - return Response({'Bad Request':'Order ID parameter not found in request'}, status=status.HTTP_400_BAD_REQUEST) + # 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 + + # If status is 'waiting for maker bond', reply with a hodl invoice too. + if order.status == Order.Status.WFB and data['is_maker']: + data['hodl_invoice'] = Logics.gen_maker_hodl_invoice(order, request.user) + + 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): From 805b12de652d930fbac449ccb7cc6aaae03e130e Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 08:20:04 -0800 Subject: [PATCH 03/12] Add preliminary pricing for t0 and maker bond. Add reverse on_delete Cascade Orders -> Htlcs --- api/admin.py | 2 +- api/models.py | 41 +++++++++++++++++++++++++++++++---------- api/views.py | 11 +++++++++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/api/admin.py b/api/admin.py index f496caa8..5e8f2aca 100644 --- a/api/admin.py +++ b/api/admin.py @@ -24,7 +24,7 @@ class EUserAdmin(UserAdmin): @admin.register(Order) class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): - list_display = ('id','type','maker_link','taker_link','status','amount','currency','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link') + list_display = ('id','type','maker_link','taker_link','status','amount','currency','t0_satoshis','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link') list_display_links = ('id','type') change_links = ('maker','taker','buyer_invoice','maker_bond','taker_invoice','taker_bond','trade_escrow') diff --git a/api/models.py b/api/models.py index 44a2f7ab..ad6de46e 100644 --- a/api/models.py +++ b/api/models.py @@ -9,6 +9,7 @@ from datetime import timedelta from django.utils import timezone from pathlib import Path +import requests from .lightning import LNNode @@ -21,6 +22,8 @@ MAX_TRADE = 500*1000 FEE = 0.002 # Trade fee in % BOND_SIZE = 0.01 # Bond in % ESCROW_USERNAME = 'admin' +MARKET_PRICE_API = 'https://blockchain.info/ticker' + class LNPayment(models.Model): @@ -133,6 +136,16 @@ class Order(models.Model): # buyer payment LN invoice buyer_invoice = models.ForeignKey(LNPayment, related_name='buyer_invoice', on_delete=models.SET_NULL, null=True, default=None, blank=True) +@receiver(pre_delete, sender=Order) +def delelete_HTLCs_at_order_deletion(sender, instance, **kwargs): + to_delete = (instance.maker_bond, instance.buyer_invoice, instance.taker_bond, instance.trade_escrow) + + for htlc in to_delete: + try: + htlc.delete() + except: + pass + class Profile(models.Model): user = models.OneToOneField(User,on_delete=models.CASCADE) @@ -207,11 +220,17 @@ class Logics(): def satoshis_now(order): ''' checks trade amount in sats ''' - # TODO - # order.last_satoshis = - # order.save() + if order.is_explicit: + satoshis_now = order.satoshis + else: + 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 50000 + return satoshis_now def order_expires(order): order.status = Order.Status.EXP @@ -256,11 +275,12 @@ class Logics(): return False, {'Order expired':'cannot generate a bond invoice for an expired order. Make a new one.'} if order.maker_bond: - return order.maker_bond.invoice - - bond_amount = cls.satoshis_now(order) + 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_amount, description=description) + invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(num_satoshis = bond_satoshis, description=description) order.maker_bond = LNPayment.objects.create( concept = LNPayment.Concepts.MAKEBOND, @@ -269,12 +289,13 @@ class Logics(): receiver = User.objects.get(username=ESCROW_USERNAME), invoice = invoice, status = LNPayment.Status.INVGEN, - num_satoshis = bond_amount, + num_satoshis = bond_satoshis, description = description, payment_hash = payment_hash, expires_at = expires_at, ) order.save() - return invoice + + return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} diff --git a/api/views.py b/api/views.py index 0ea88ee4..a583f52e 100644 --- a/api/views.py +++ b/api/views.py @@ -58,8 +58,11 @@ class OrderMakerView(CreateAPIView): premium=premium, satoshis=satoshis, is_explicit=is_explicit, - expires_at= timezone.now()+timedelta(minutes=EXPIRATION_MAKE), + expires_at=timezone.now()+timedelta(minutes=EXPIRATION_MAKE), maker=request.user) + + 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() if not serializer.is_valid(): @@ -112,7 +115,11 @@ class OrderView(viewsets.ViewSet): # If status is 'waiting for maker bond', reply with a hodl invoice too. if order.status == Order.Status.WFB and data['is_maker']: - data['hodl_invoice'] = Logics.gen_maker_hodl_invoice(order, request.user) + valid, context = Logics.gen_maker_hodl_invoice(order, request.user) + if valid: + data = {**data, **context} + else: + Response(context, status=status.HTTP_400_BAD_REQUEST) return Response(data, status=status.HTTP_200_OK) From a1771ae5eafdf77a9074a03abf6649b18a183115 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 08:54:37 -0800 Subject: [PATCH 04/12] Add environmental variables to .env --- .env-sample | 17 ++++++ api/logics.py | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++ api/models.py | 128 ++--------------------------------------- api/views.py | 9 +-- setup.md | 5 +- 5 files changed, 186 insertions(+), 127 deletions(-) create mode 100644 .env-sample create mode 100644 api/logics.py diff --git a/.env-sample b/.env-sample new file mode 100644 index 00000000..2e88f3ec --- /dev/null +++ b/.env-sample @@ -0,0 +1,17 @@ +# Market price public API +MARKET_PRICE_API = 'https://blockchain.info/ticker' + +# Trade fee in percentage % +FEE = 0.002 +# Bond size in percentage % +BOND_SIZE = 0.01 + +# Trade limits in satoshis +MIN_TRADE = 10000 +MAX_TRADE = 500000 + +# Expiration time in minutes +EXPIRATION_MAKE = 5 + +# Username for HTLCs escrows +ESCROW_USERNAME = 'admin' \ No newline at end of file diff --git a/api/logics.py b/api/logics.py new file mode 100644 index 00000000..77c87399 --- /dev/null +++ b/api/logics.py @@ -0,0 +1,154 @@ +from datetime import timedelta +from django.utils import timezone +import requests +from .lightning import LNNode + +from .models import Order, LNPayment, User +from decouple import config + +FEE = float(config('FEE')) +BOND_SIZE = float(config('BOND_SIZE')) +MARKET_PRICE_API = config('MARKET_PRICE_API') +ESCROW_USERNAME = config('ESCROW_USERNAME') + + +class Logics(): + + # escrow_user = User.objects.get(username=ESCROW_USERNAME) + + def validate_already_maker_or_taker(user): + '''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'} + queryset = Order.objects.filter(taker=user) + if queryset.exists(): + return False, {'Bad Request':'You are already taker of an order'} + return True, None + + def take(order, user): + order.taker = user + order.status = Order.Status.TAK + order.save() + + def is_buyer(order, user): + is_maker = order.maker == user + is_taker = order.taker == user + return (is_maker and order.type == Order.Types.BUY) or (is_taker and order.type == Order.Types.SELL) + + def is_seller(order, user): + is_maker = order.maker == user + is_taker = order.taker == user + return (is_maker and order.type == Order.Types.SELL) or (is_taker and order.type == Order.Types.BUY) + + def satoshis_now(order): + ''' checks trade amount in sats ''' + if order.is_explicit: + satoshis_now = order.satoshis + else: + 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 + + def order_expires(order): + order.status = Order.Status.EXP + order.maker = None + order.taker = None + order.save() + + @classmethod + def update_invoice(cls, order, user, invoice): + is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) + # only user is the buyer and a valid LN invoice + if cls.is_buyer(order, user) and is_valid_invoice: + order.buyer_invoice, _ = LNPayment.objects.update_or_create( + concept = LNPayment.Concepts.PAYBUYER, + type = LNPayment.Types.NORM, + sender = User.objects.get(username=ESCROW_USERNAME), + receiver= user, + # if there is a LNPayment matching these above, it updates that with defaults below. + defaults={ + 'invoice' : invoice, + 'status' : LNPayment.Status.VALIDI, + 'num_satoshis' : num_satoshis, + 'description' : description, + 'payment_hash' : payment_hash, + 'expires_at' : expires_at} + ) + + #If the order status was Payment Failed. Move foward to invoice Updated. + if order.status == Order.Status.FAI: + order.status = Order.Status.UPI + order.save() + return True + + return False + + @classmethod + def gen_maker_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.'} + + 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) + + order.maker_bond = LNPayment.objects.create( + concept = LNPayment.Concepts.MAKEBOND, + type = LNPayment.Types.HODL, + sender = user, + receiver = User.objects.get(username=ESCROW_USERNAME), + invoice = invoice, + status = LNPayment.Status.INVGEN, + num_satoshis = bond_satoshis, + description = description, + payment_hash = payment_hash, + expires_at = expires_at, + ) + + order.save() + return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} + + @classmethod + def gen_taker_buyer_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.'} + + 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) + + order.maker_bond = LNPayment.objects.create( + concept = LNPayment.Concepts.MAKEBOND, + type = LNPayment.Types.HODL, + sender = user, + receiver = User.objects.get(username=ESCROW_USERNAME), + invoice = invoice, + status = LNPayment.Status.INVGEN, + num_satoshis = bond_satoshis, + description = description, + payment_hash = payment_hash, + 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 ad6de46e..655189cc 100644 --- a/api/models.py +++ b/api/models.py @@ -5,24 +5,18 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from django.utils.html import mark_safe -from datetime import timedelta -from django.utils import timezone - +from decouple import config from pathlib import Path -import requests - -from .lightning import LNNode ############################# # TODO # Load hparams from .env file -MIN_TRADE = 10*1000 #In sats -MAX_TRADE = 500*1000 -FEE = 0.002 # Trade fee in % -BOND_SIZE = 0.01 # Bond in % -ESCROW_USERNAME = 'admin' -MARKET_PRICE_API = 'https://blockchain.info/ticker' +MIN_TRADE = int(config('MIN_TRADE')) +MAX_TRADE = int(config('MAX_TRADE')) +FEE = float(config('FEE')) +BOND_SIZE = float(config('BOND_SIZE')) + class LNPayment(models.Model): @@ -189,113 +183,3 @@ class Profile(models.Model): def avatar_tag(self): return mark_safe('' % self.get_avatar()) -class Logics(): - - # escrow_user = User.objects.get(username=ESCROW_USERNAME) - - def validate_already_maker_or_taker(user): - '''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'} - queryset = Order.objects.filter(taker=user) - if queryset.exists(): - return False, {'Bad Request':'You are already taker of an order'} - return True, None - - def take(order, user): - order.taker = user - order.status = Order.Status.TAK - order.save() - - def is_buyer(order, user): - is_maker = order.maker == user - is_taker = order.taker == user - return (is_maker and order.type == Order.Types.BUY) or (is_taker and order.type == Order.Types.SELL) - - def is_seller(order, user): - is_maker = order.maker == user - is_taker = order.taker == user - return (is_maker and order.type == Order.Types.SELL) or (is_taker and order.type == Order.Types.BUY) - - def satoshis_now(order): - ''' checks trade amount in sats ''' - if order.is_explicit: - satoshis_now = order.satoshis - else: - 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 - - def order_expires(order): - order.status = Order.Status.EXP - order.maker = None - order.taker = None - order.save() - - @classmethod - def update_invoice(cls, order, user, invoice): - is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) - # only user is the buyer and a valid LN invoice - if cls.is_buyer(order, user) and is_valid_invoice: - order.buyer_invoice, _ = LNPayment.objects.update_or_create( - concept = LNPayment.Concepts.PAYBUYER, - type = LNPayment.Types.NORM, - sender = User.objects.get(username=ESCROW_USERNAME), - receiver= user, - # if there is a LNPayment matching these above, it updates that with defaults below. - defaults={ - 'invoice' : invoice, - 'status' : LNPayment.Status.VALIDI, - 'num_satoshis' : num_satoshis, - 'description' : description, - 'payment_hash' : payment_hash, - 'expires_at' : expires_at} - ) - - #If the order status was Payment Failed. Move foward to invoice Updated. - if order.status == Order.Status.FAI: - order.status = Order.Status.UPI - order.save() - return True - - return False - - @classmethod - def gen_maker_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.'} - - 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) - - order.maker_bond = LNPayment.objects.create( - concept = LNPayment.Concepts.MAKEBOND, - type = LNPayment.Types.HODL, - sender = user, - receiver = User.objects.get(username=ESCROW_USERNAME), - invoice = invoice, - status = LNPayment.Status.INVGEN, - num_satoshis = bond_satoshis, - description = description, - payment_hash = payment_hash, - expires_at = expires_at, - ) - - order.save() - - return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} - diff --git a/api/views.py b/api/views.py index a583f52e..68a09041 100644 --- a/api/views.py +++ b/api/views.py @@ -8,8 +8,8 @@ from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import User from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateInvoiceSerializer -from .models import Order, LNPayment, Logics -from .lightning import LNNode +from .models import Order +from .logics import Logics from .nick_generator.nick_generator import NickGenerator from robohash import Robohash @@ -21,8 +21,9 @@ from pathlib import Path from datetime import timedelta from django.utils import timezone -# .env -EXPIRATION_MAKE = 5 # minutes +from decouple import config + +EXPIRATION_MAKE = config('EXPIRATION_MAKE') avatar_path = Path('frontend/static/assets/avatars') avatar_path.mkdir(parents=True, exist_ok=True) diff --git a/setup.md b/setup.md index c9c82cbf..c1cbf307 100644 --- a/setup.md +++ b/setup.md @@ -4,7 +4,10 @@ `sudo apt install python3 python3 pip` ### Install virtual environments -`pip install virtualenvwrapper` +``` +pip install virtualenvwrapper +pip install python-decouple +``` ### Add to .bashrc From 34e05465c2919b6e7b5a1435da9f77bd1d6f5451 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 12:33:40 -0800 Subject: [PATCH 05/12] 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()) From c0d6236dbb5a9fcd6f94fab33d0e8a4366555f67 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 13:36:22 -0800 Subject: [PATCH 06/12] Maker and taker bonds OK --- api/logics.py | 29 +++++++++++++++++++--------- api/serializers.py | 4 ++-- api/views.py | 48 ++++++++++++++++++++++++++++++---------------- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/api/logics.py b/api/logics.py index 5fcdfa95..e3ed98c5 100644 --- a/api/logics.py +++ b/api/logics.py @@ -11,6 +11,9 @@ BOND_SIZE = float(config('BOND_SIZE')) MARKET_PRICE_API = config('MARKET_PRICE_API') ESCROW_USERNAME = config('ESCROW_USERNAME') +MIN_TRADE = int(config('MIN_TRADE')) +MAX_TRADE = int(config('MAX_TRADE')) + 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')) @@ -32,6 +35,14 @@ class Logics(): 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}'} + 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 True, None + def take(order, user): order.taker = user order.status = Order.Status.TAK @@ -135,7 +146,7 @@ class Logics(): 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 + bond_satoshis = int(order.satoshis_now * BOND_SIZE) description = f'RoboSats - Maker bond for order ID {order.id}. These sats will return to you if you do not cheat!' # Gen HODL Invoice @@ -160,16 +171,16 @@ class Logics(): def gen_takerbuyer_hodl_invoice(cls, order, user): # 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.'} - - # 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} + 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.'} + else: + # Return the previous invoice if there was one + 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 + bond_satoshis = int(order.satoshis_now * BOND_SIZE) description = f'RoboSats - Taker bond for order ID {order.id}. These sats will return to you if you do not cheat!' # Gen HODL Invoice @@ -188,4 +199,4 @@ class Logics(): expires_at = expires_at) order.save() - return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} \ No newline at end of file + return True, {'invoice':invoice,'bond_satoshis': bond_satoshis} \ No newline at end of file diff --git a/api/serializers.py b/api/serializers.py index d77b1e17..a819db91 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Order, LNPayment +from .models import Order class ListOrderSerializer(serializers.ModelSerializer): class Meta: @@ -13,5 +13,5 @@ class MakeOrderSerializer(serializers.ModelSerializer): 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) + action = serializers.ChoiceField(choices=('take','update_invoice','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/views.py b/api/views.py index 1b8900a1..39ec55d1 100644 --- a/api/views.py +++ b/api/views.py @@ -1,7 +1,6 @@ -from rest_framework import status, serializers +from rest_framework import status, viewsets from rest_framework.generics import CreateAPIView, ListAPIView from rest_framework.views import APIView -from rest_framework import viewsets from rest_framework.response import Response from django.contrib.auth import authenticate, login, logout @@ -9,7 +8,7 @@ from django.contrib.auth.models import User from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer from .models import Order -from .logics import EXP_MAKER_BOND_INVOICE, Logics +from .logics import Logics from .nick_generator.nick_generator import NickGenerator from robohash import Robohash @@ -20,7 +19,6 @@ import hashlib from pathlib import Path from datetime import timedelta from django.utils import timezone - from decouple import config EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE')) @@ -47,7 +45,7 @@ class OrderMakerView(CreateAPIView): is_explicit = serializer.data.get('is_explicit') valid, context = Logics.validate_already_maker_or_taker(request.user) - if not valid: return Response(context, status=status.HTTP_409_CONFLICT) + if not valid: return Response(context, status.HTTP_409_CONFLICT) # Creates a new order order = Order( @@ -58,10 +56,14 @@ class OrderMakerView(CreateAPIView): premium=premium, satoshis=satoshis, is_explicit=is_explicit, - expires_at=timezone.now()+timedelta(minutes=EXP_MAKER_BOND_INVOICE), + expires_at=timezone.now()+timedelta(minutes=EXP_MAKER_BOND_INVOICE), # TODO Move to class method maker=request.user) + + # TODO move to Order class method when new instance is created! + order.last_satoshis = order.t0_satoshis = Logics.satoshis_now(order) - order.last_satoshis = order.t0_satoshis = Logics.satoshis_now(order) # TODO move to Order class method when new instance is created! + 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) @@ -112,25 +114,37 @@ class OrderView(viewsets.ViewSet): # 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) - data = {**data, **context} if valid else Response(context, status.HTTP_400_BAD_REQUEST) + if valid: + data = {**data, **context} + else: + return 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']: + # 5) If status is 'Taken' and user is taker/buyer, reply with a TAKER HODL invoice. + elif order.status == Order.Status.TAK 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) + if valid: + data = {**data, **context} + else: + return 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) + if valid: + data = {**data, **context} + else: + return 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) + if valid: + data = {**data, **context} + else: + return Response(context, 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) + return Response(data, status.HTTP_200_OK) + return Response({'Order Not Found':'Invalid Order Id'}, status.HTTP_404_NOT_FOUND) def take_update_confirm_dispute_cancel(self, request, format=None): order_id = request.GET.get(self.lookup_url_kwarg) @@ -140,7 +154,7 @@ class OrderView(viewsets.ViewSet): order = Order.objects.get(id=order_id) - # action is either 1)'take', 2)'confirm', 3)'cancel', 4)'dispute' , 5)'update' (invoice) 6)'rate' (counterparty) + # 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') @@ -232,7 +246,7 @@ class UserView(APIView): with open(image_path, "wb") as f: rh.img.save(f, format="png") - # Create new credentials and logsin if nickname is new + # Create new credentials and log in if nickname is new if len(User.objects.filter(username=nickname)) == 0: User.objects.create_user(username=nickname, password=token, is_staff=False) user = authenticate(request, username=nickname, password=token) From 31b19ce18c8a3ce30139063b2737400849de9776 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 14:39:59 -0800 Subject: [PATCH 07/12] Work on order cancel --- api/logics.py | 106 +++++++++++++++++++++++++++++++------------------- api/models.py | 8 ++-- api/views.py | 43 ++++++++++---------- 3 files changed, 92 insertions(+), 65 deletions(-) diff --git a/api/logics.py b/api/logics.py index e3ed98c5..340d9f7e 100644 --- a/api/logics.py +++ b/api/logics.py @@ -80,57 +80,81 @@ class Logics(): def update_invoice(cls, order, user, invoice): is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) # only user is the buyer and a valid LN invoice - if cls.is_buyer(order, user) and is_valid_invoice: - order.buyer_invoice, _ = LNPayment.objects.update_or_create( - concept = LNPayment.Concepts.PAYBUYER, - type = LNPayment.Types.NORM, - sender = User.objects.get(username=ESCROW_USERNAME), - receiver= user, - # if there is a LNPayment matching these above, it updates that with defaults below. - defaults={ - 'invoice' : invoice, - 'status' : LNPayment.Status.VALIDI, - 'num_satoshis' : num_satoshis, - 'description' : description, - 'payment_hash' : payment_hash, - 'expires_at' : expires_at} - ) + if not (cls.is_buyer(order, user) or is_valid_invoice): + return False, {'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'} - #If the order status was Payment Failed. Move foward to invoice Updated. - if order.status == Order.Status.FAI: - order.status = Order.Status.UPI - order.save() - return True, None + order.buyer_invoice, _ = LNPayment.objects.update_or_create( + concept = LNPayment.Concepts.PAYBUYER, + type = LNPayment.Types.NORM, + sender = User.objects.get(username=ESCROW_USERNAME), + receiver= user, + # if there is a LNPayment matching these above, it updates that one with defaults below. + defaults={ + 'invoice' : invoice, + 'status' : LNPayment.Status.VALIDI, + 'num_satoshis' : num_satoshis, + 'description' : description, + 'payment_hash' : payment_hash, + 'expires_at' : expires_at} + ) - return False, {'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'} + # If the order status is 'Waiting for invoice'. Move forward to 'waiting for invoice' + if order.status == Order.Status.WFE: order.status = Order.Status.CHA + + # If the order status is 'Waiting for both'. Move forward to 'waiting for escrow' or to 'chat' + if order.status == Order.Status.WF2: + print(order.trade_escrow) + if order.trade_escrow: + if order.trade_escrow.status == LNPayment.Status.LOCKED: + order.status = Order.Status.CHA + else: + order.status = Order.Status.WFE + + # If the order status was Payment Failed. Move forward to invoice Updated. + if order.status == Order.Status.FAI: + order.status = Order.Status.UPI + + order.save() + return True, None + + @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.''' + '''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: + order.maker = None + order.status = Order.Status.UCA + order.save() + return True, None - # 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() ''' + # 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''' - # 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.''' + # 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.''' + + else: + return False, {'bad_request':'You cannot cancel this order'} - # 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 @classmethod @@ -168,7 +192,7 @@ class Logics(): return True, {'invoice':invoice,'bond_satoshis':bond_satoshis} @classmethod - def gen_takerbuyer_hodl_invoice(cls, order, user): + def gen_taker_hodl_invoice(cls, order, user): # Do not gen and cancel if a taker invoice is there and older than 2 minutes if order.taker_bond: diff --git a/api/models.py b/api/models.py index 2c2ad4f7..b9a5ae66 100644 --- a/api/models.py +++ b/api/models.py @@ -77,12 +77,12 @@ class Order(models.Model): WFB = 0, 'Waiting for maker bond' PUB = 1, 'Public' DEL = 2, 'Deleted' - TAK = 3, 'Waiting for taker bond' # only needed when taker is a buyer + TAK = 3, 'Waiting for taker bond' 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' + 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' diff --git a/api/views.py b/api/views.py index 39ec55d1..ea200022 100644 --- a/api/views.py +++ b/api/views.py @@ -89,6 +89,12 @@ class OrderView(viewsets.ViewSet): 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) + data = ListOrderSerializer(order).data # Add booleans if user is maker, taker, partipant, buyer or seller @@ -96,11 +102,11 @@ class OrderView(viewsets.ViewSet): data['is_taker'] = order.taker == request.user data['is_participant'] = data['is_maker'] or data['is_taker'] - # 2) If not a participant and order is not public, forbid. + # 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) - # 3) Non participants can view details (but only if PUB) + # 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) @@ -111,7 +117,7 @@ class OrderView(viewsets.ViewSet): data['taker_nick'] = str(order.taker) data['status_message'] = Order.Status(order.status).label - # 4) If status is 'waiting for maker bond', reply with a MAKER HODL invoice. + # 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: @@ -119,24 +125,16 @@ class OrderView(viewsets.ViewSet): else: return Response(context, status.HTTP_400_BAD_REQUEST) - # 5) If status is 'Taken' and user is taker/buyer, reply with a TAKER HODL invoice. - elif order.status == Order.Status.TAK and data['is_taker'] and data['is_buyer']: - valid, context = Logics.gen_takerbuyer_hodl_invoice(order, request.user) - if valid: - data = {**data, **context} - else: - return 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) + # 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/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']: + # 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} @@ -147,6 +145,10 @@ class OrderView(viewsets.ViewSet): return Response({'Order Not Found':'Invalid Order Id'}, status.HTTP_404_NOT_FOUND) def take_update_confirm_dispute_cancel(self, request, format=None): + ''' + Here take place all of the user 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) @@ -169,13 +171,14 @@ class OrderView(viewsets.ViewSet): 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) + elif action == 'update_invoice' and invoice: + valid, context = Logics.update_invoice(order,request.user,invoice) + if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) # 3) If action is cancel elif action == 'cancel': - pass + valid, context = Logics.cancel_order(order,request.user,invoice) + if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) # 4) If action is confirm elif action == 'confirm': From 6a1a906beafa7b58c26c443f028cf4f16d654230 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 6 Jan 2022 15:33:55 -0800 Subject: [PATCH 08/12] Cosmetic --- api/logics.py | 5 ++--- api/models.py | 4 ++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/api/logics.py b/api/logics.py index 340d9f7e..f8ebab04 100644 --- a/api/logics.py +++ b/api/logics.py @@ -131,7 +131,6 @@ class Logics(): order.save() return True, None - # 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 @@ -171,7 +170,7 @@ class Logics(): order.satoshis_now = cls.satoshis_now(order) bond_satoshis = int(order.satoshis_now * BOND_SIZE) - description = f'RoboSats - Maker bond for order ID {order.id}. These sats will return to you if you do not cheat!' + description = f'RoboSats - Publishing {str(order)} - This bond 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) @@ -205,7 +204,7 @@ class Logics(): order.satoshis_now = cls.satoshis_now(order) bond_satoshis = int(order.satoshis_now * BOND_SIZE) - description = f'RoboSats - Taker bond for order ID {order.id}. These sats will return to you if you do not cheat!' + description = f'RoboSats - Taking {str(order)} - This bond 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) diff --git a/api/models.py b/api/models.py index b9a5ae66..1524ef8d 100644 --- a/api/models.py +++ b/api/models.py @@ -128,6 +128,10 @@ class Order(models.Model): # buyer payment LN invoice buyer_invoice = models.ForeignKey(LNPayment, related_name='buyer_invoice', on_delete=models.SET_NULL, null=True, default=None, blank=True) + 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}') + @receiver(pre_delete, sender=Order) def delelete_HTLCs_at_order_deletion(sender, instance, **kwargs): to_delete = (instance.maker_bond, instance.buyer_invoice, instance.taker_bond, instance.trade_escrow) From 8a55383761c6b71d13119e503966ac634834fec1 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 7 Jan 2022 03:31:33 -0800 Subject: [PATCH 09/12] 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, }); }); } From 9ab52853d5288ef061d838af6aba38def7c85836 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 7 Jan 2022 10:22:52 -0800 Subject: [PATCH 10/12] Work on more logics. Rough draft finished --- api/admin.py | 2 +- api/logics.py | 76 +++++++++++++++++++++++----- api/models.py | 36 ++++++++----- api/urls.py | 5 +- api/views.py | 64 ++++++++++++++++++----- frontend/src/components/MakerPage.js | 3 +- 6 files changed, 142 insertions(+), 44 deletions(-) diff --git a/api/admin.py b/api/admin.py index b45ca85b..9b57b651 100644 --- a/api/admin.py +++ b/api/admin.py @@ -24,7 +24,7 @@ class EUserAdmin(UserAdmin): @admin.register(Order) class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): - list_display = ('id','type','maker_link','taker_link','status','amount','currency','t0_satoshis','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link') + list_display = ('id','type','maker_link','taker_link','status','amount','currency','t0_satoshis','is_disputed','is_fiat_sent','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link') list_display_links = ('id','type') change_links = ('maker','taker','buyer_invoice','maker_bond','taker_invoice','taker_bond','trade_escrow') diff --git a/api/logics.py b/api/logics.py index a0f6c62a..5a8afe12 100644 --- a/api/logics.py +++ b/api/logics.py @@ -23,8 +23,6 @@ ESCROW_EXPIRY = int(config('ESCROW_EXPIRY')) class Logics(): - # escrow_user = User.objects.get(username=ESCROW_USERNAME) - def validate_already_maker_or_taker(user): '''Checks if the user is already partipant of an order''' queryset = Order.objects.filter(maker=user) @@ -130,16 +128,23 @@ class Logics(): @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) + + # If the trade is finished + if order.status > Order.Status.PAY: + + # 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) + else: + return False, {'bad_request':'You cannot rate your counterparty yet.'} order.save() return True, None @@ -291,4 +296,49 @@ class Logics(): expires_at = expires_at) order.save() - return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis} \ No newline at end of file + return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis} + + def settle_escrow(order): + ''' Settles the trade escrow HTLC''' + # TODO ERROR HANDLING + + valid = LNNode.settle_hodl_htlcs(order.trade_escrow.payment_hash) + return valid + + def pay_buyer_invoice(order): + ''' Settles the trade escrow HTLC''' + # TODO ERROR HANDLING + + valid = LNNode.pay_invoice(order.buyer_invoice.payment_hash) + return valid + + @classmethod + def confirm_fiat(cls, order, user): + ''' If Order is in the CHAT states: + If user is buyer: mark the FIAT SENT andettle escrow! + If User is the seller and FIAT was already sent: Pay buyer invoice!''' + + if order.status == Order.Status.CHA or order.status == Order.Status.FSE: # TODO Alternatively, if all collateral is locked? test out + + # If buyer, settle escrow and mark fiat sent + if cls.is_buyer(order, user): + if cls.settle_escrow(order): # KEY LINE - SETTLES THE TRADE ESCROW !! + order.trade_escrow.status = LNPayment.Status.SETLED + order.status = Order.Status.FSE + order.is_fiat_sent = True + + # If seller and fiat sent, pay buyer invoice + elif cls.is_seller(order, user): + if not order.is_fiat_sent: + return False, {'bad_request':'You cannot confirm to have received the fiat before it is confirmed to be sent by the buyer.'} + + # Double check the escrow is settled. + if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash): + if cls.pay_buyer_invoice(order): # KEY LINE - PAYS THE BUYER !! + order.status = Order.Status.PAY + order.buyer_invoice.status = LNPayment.Status.PAYING + else: + return False, {'bad_request':'You cannot confirm the fiat payment at this stage'} + + order.save() + return True, None \ No newline at end of file diff --git a/api/models.py b/api/models.py index accc8e18..4f818b62 100644 --- a/api/models.py +++ b/api/models.py @@ -38,7 +38,8 @@ class LNPayment(models.Model): RETNED = 3, 'Returned' MISSNG = 4, 'Missing' VALIDI = 5, 'Valid' - INFAIL = 6, 'Failed routing' + PAYING = 6, 'Paying ongoing' + FAILRO = 7, 'Failed routing' # payment use details type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.HODL) @@ -78,20 +79,20 @@ class Order(models.Model): 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' + 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' - FCO = 11, 'Fiat confirmed' - SUC = 12, 'Sucessfully settled' - FAI = 13, 'Failed lightning network routing' - UPI = 14, 'Updated invoice' - DIS = 15, 'In dispute' + DIS = 11, 'In dispute' + CCA = 12, 'Collaboratively cancelled' + PAY = 13, 'Sending satoshis to buyer' + SUC = 14, 'Sucessfully settled' + FAI = 15, 'Failed lightning network routing' 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) @@ -102,7 +103,7 @@ class Order(models.Model): type = models.PositiveSmallIntegerField(choices=Types.choices, null=False) currency = models.PositiveSmallIntegerField(choices=Currencies.choices, null=False) amount = models.DecimalField(max_digits=9, decimal_places=4, validators=[MinValueValidator(0.00001)]) - payment_method = models.CharField(max_length=30, null=False, default="not specified", blank=True) + payment_method = models.CharField(max_length=50, 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) @@ -118,8 +119,11 @@ class Order(models.Model): 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. + is_disputed = models.BooleanField(default=False, null=False) + is_fiat_sent = models.BooleanField(default=False, null=False) - # order collateral + # HTLCs + # 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) trade_escrow = models.ForeignKey(LNPayment, related_name='trade_escrow', on_delete=models.SET_NULL, null=True, default=None, blank=True) @@ -127,6 +131,10 @@ class Order(models.Model): # buyer payment LN invoice buyer_invoice = models.ForeignKey(LNPayment, related_name='buyer_invoice', on_delete=models.SET_NULL, null=True, default=None, blank=True) + # cancel LN invoice // these are only needed to charge lower-than-bond amounts. E.g., a taken order has a small cost if cancelled, to avoid DDOSing. + maker_cancel = models.ForeignKey(LNPayment, related_name='maker_cancel', on_delete=models.SET_NULL, null=True, default=None, blank=True) + taker_cancel = models.ForeignKey(LNPayment, related_name='taker_cancel', on_delete=models.SET_NULL, null=True, default=None, blank=True) + def __str__(self): # Make relational back to ORDER return (f'Order {self.id}: {self.Types(self.type).label} BTC for {self.amount} {self.Currencies(self.currency).label}') diff --git a/api/urls.py b/api/urls.py index b10893c8..7ba757ba 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .views import OrderMakerView, OrderView, UserView, BookView +from .views import MakerView, OrderView, UserView, BookView, InfoView urlpatterns = [ - path('make/', OrderMakerView.as_view()), + path('make/', MakerView.as_view()), path('order/', OrderView.as_view({'get':'get','post':'take_update_confirm_dispute_cancel'})), path('usergen/', UserView.as_view()), path('book/', BookView.as_view()), + path('info/', InfoView.as_view()), ] \ No newline at end of file diff --git a/api/views.py b/api/views.py index c1cf3897..71d5b2e8 100644 --- a/api/views.py +++ b/api/views.py @@ -22,13 +22,14 @@ from django.utils import timezone from decouple import config EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE')) +FEE = float(config('FEE')) avatar_path = Path('frontend/static/assets/avatars') avatar_path.mkdir(parents=True, exist_ok=True) # Create your views here. -class OrderMakerView(CreateAPIView): +class MakerView(CreateAPIView): serializer_class = MakeOrderSerializer def post(self,request): @@ -121,7 +122,19 @@ class OrderView(viewsets.ViewSet): 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 + data['status_message'] = Order.Status(order.status).label + data['is_fiat_sent'] = order.is_fiat_sent + data['is_disputed'] = order.is_disputed + + # If both bonds are locked, participants can see the trade in sats is also final. + if order.taker_bond: + if order.maker_bond.status == order.taker_bond.status == order.trade_escrow.status == LNPayment.Status.LOCKED: + # Seller sees the amount he pays + if data['is_seller']: + data['trade_satoshis'] = order.last_satoshis + # Buyer sees the amount he receives + elif data['is_buyer']: + data['trade_satoshis'] = order.last_satoshis * (1-FEE) # 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']: @@ -166,7 +179,11 @@ class OrderView(viewsets.ViewSet): 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 - + + # 9) if buyer confirmed FIAT SENT + elif order.status == Order.Status.FSE: + data['buyer_confirmed'] + return Response(data, status.HTTP_200_OK) def take_update_confirm_dispute_cancel(self, request, format=None): @@ -195,32 +212,42 @@ class OrderView(viewsets.ViewSet): 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_invoice' and invoice: + # 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' + if action == 'update_invoice' and invoice: valid, context = Logics.update_invoice(order,request.user,invoice) - if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) + 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) + if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) # 4) If action is confirm elif action == 'confirm': - pass + valid, context = Logics.confirm_fiat(order,request.user) + if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) # 5) If action is dispute elif action == 'dispute': - pass + valid, context = Logics.open_dispute(order,request.user, rating) + if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 6) If action is dispute + # 6) If action is rate elif action == 'rate' and rating: valid, context = Logics.rate_counterparty(order,request.user, rating) - if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) + if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # If nothing... something else is going on. Probably not allowed! + # 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'}) + 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) @@ -337,6 +364,17 @@ class BookView(ListAPIView): book_data.append(data) return Response(book_data, status=status.HTTP_200_OK) + +class InfoView(ListAPIView): + + def get(self, request, format = None): + 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.BUY, status=Order.Status.PUB)) + context['num_active_robots'] = None # Todo + context['total_volume'] = None + + return Response(context, status.HTTP_200_ok) diff --git a/frontend/src/components/MakerPage.js b/frontend/src/components/MakerPage.js index a98e5049..6b1ee1b1 100644 --- a/frontend/src/components/MakerPage.js +++ b/frontend/src/components/MakerPage.js @@ -177,7 +177,8 @@ export default class MakerPage extends Component { type="text" require={true} inputProps={{ - style: {textAlign:"center"} + style: {textAlign:"center"}, + maxLength: 50 }} onChange={this.handlePaymentMethodChange} /> From b472b4928c26c52063bc053b650fabbe68c53f7b Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 7 Jan 2022 11:22:07 -0800 Subject: [PATCH 11/12] More logics, bug hunt --- api/lightning.py | 22 +++++++++++++--------- api/logics.py | 31 +++++++++++++++++++++++-------- api/models.py | 2 +- api/views.py | 4 ++-- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/api/lightning.py b/api/lightning.py index cf66a12f..a10f258b 100644 --- a/api/lightning.py +++ b/api/lightning.py @@ -22,32 +22,36 @@ class LNNode(): return invoice, payment_hash, expires_at - def validate_hodl_invoice_locked(): + def validate_hodl_invoice_locked(payment_hash): '''Generates hodl invoice to publish an order''' return True - def validate_ln_invoice(invoice): # num_satoshis + def validate_ln_invoice(invoice, num_satoshis): # num_satoshis '''Checks if the submited LN invoice is as expected''' valid = True - num_satoshis = 50000 # TODO decrypt and confirm sats are as expected + context = None description = 'Placeholder desc' # TODO decrypt from LN invoice - payment_hash = '567126' # TODO decrypt + payment_hash = '567&*GIHU126' # TODO decrypt expires_at = timezone.now() # TODO decrypt - return valid, num_satoshis, description, payment_hash, expires_at + return valid, context, description, payment_hash, expires_at - def pay_buyer_invoice(invoice): - '''Sends sats to buyer''' + def pay_invoice(invoice): + '''Sends sats to buyer, or cancelinvoices''' return True - def charge_hodl_htlcs(invoice): + def settle_hodl_htlcs(payment_hash): '''Charges a LN hodl invoice''' return True - def free_hodl_htlcs(invoice): + def return_hodl_htlcs(payment_hash): '''Returns sats''' return True + def double_check_htlc_is_settled(payment_hash): + ''' Just as it sounds. Better safe than sorry!''' + return True + diff --git a/api/logics.py b/api/logics.py index 5a8afe12..19dd971b 100644 --- a/api/logics.py +++ b/api/logics.py @@ -87,10 +87,19 @@ class Logics(): @classmethod def update_invoice(cls, order, user, invoice): - is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice) - # only user is the buyer and a valid LN invoice - if not (cls.is_buyer(order, user) or is_valid_invoice): - return False, {'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'} + + # only the buyer can post a buyer invoice + if not cls.is_buyer(order, user): + return False, {'bad_request':'Only the buyer of this order can provide a buyer invoice.'} + if not order.taker_bond: + return False, {'bad_request':'Wait for your order to be taken.'} + if not (order.taker_bond.status == order.maker_bond.status == LNPayment.Status.LOCKED): + return False, {'bad_request':'You cannot a invoice while bonds are not posted.'} + + num_satoshis = cls.buyer_invoice_amount(order, user)[1]['invoice_amount'] + valid, context, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice, num_satoshis) + if not valid: + return False, context order.buyer_invoice, _ = LNPayment.objects.update_or_create( concept = LNPayment.Concepts.PAYBUYER, @@ -150,7 +159,7 @@ class Logics(): return True, None @classmethod - def cancel_order(cls, order, user, state): + def cancel_order(cls, order, user, state=None): # 1) When maker cancels before bond '''The order never shows up on the book and order @@ -315,8 +324,8 @@ class Logics(): @classmethod def confirm_fiat(cls, order, user): ''' If Order is in the CHAT states: - If user is buyer: mark the FIAT SENT andettle escrow! - If User is the seller and FIAT was already sent: Pay buyer invoice!''' + If user is buyer: mark FIAT SENT and settle escrow! + If User is the seller and FIAT is SENT: Pay buyer invoice!''' if order.status == Order.Status.CHA or order.status == Order.Status.FSE: # TODO Alternatively, if all collateral is locked? test out @@ -334,7 +343,13 @@ class Logics(): # Double check the escrow is settled. if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash): - if cls.pay_buyer_invoice(order): # KEY LINE - PAYS THE BUYER !! + + # Make sure the trade escrow is at least as big as the buyer invoice + if order.trade_escrow.num_satoshis <= order.buyer_invoice.num_satoshis: + return False, {'bad_request':'Woah, something broke badly. Report in the public channels, or open a Github Issue.'} + + # Double check the trade escrow is settled + elif cls.pay_buyer_invoice(order): # KEY LINE - PAYS THE BUYER !! order.status = Order.Status.PAY order.buyer_invoice.status = LNPayment.Status.PAYING else: diff --git a/api/models.py b/api/models.py index 4f818b62..054381c9 100644 --- a/api/models.py +++ b/api/models.py @@ -137,7 +137,7 @@ class Order(models.Model): def __str__(self): # Make relational back to ORDER - return (f'Order {self.id}: {self.Types(self.type).label} BTC for {self.amount} {self.Currencies(self.currency).label}') + return (f'Order {self.id}: {self.Types(self.type).label} BTC for {float(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 71d5b2e8..8ac2fd3e 100644 --- a/api/views.py +++ b/api/views.py @@ -128,13 +128,13 @@ class OrderView(viewsets.ViewSet): # If both bonds are locked, participants can see the trade in sats is also final. if order.taker_bond: - if order.maker_bond.status == order.taker_bond.status == order.trade_escrow.status == LNPayment.Status.LOCKED: + if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED: # Seller sees the amount he pays if data['is_seller']: data['trade_satoshis'] = order.last_satoshis # Buyer sees the amount he receives elif data['is_buyer']: - data['trade_satoshis'] = order.last_satoshis * (1-FEE) + data['trade_satoshis'] = Logics.buyer_invoice_amount(order, request.user)[1]['invoice_amount'] # 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']: From eb9042eaa4f32e34d7e10fb58433bcd43cc33f7f Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 7 Jan 2022 14:46:30 -0800 Subject: [PATCH 12/12] Add Non-KYC Bitcoin price historical records --- api/admin.py | 16 ++++++++++++---- api/logics.py | 14 ++++++-------- api/models.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++----- api/utils.py | 12 ++++++++++++ api/views.py | 34 ++++++++++++++++++++-------------- 5 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 api/utils.py diff --git a/api/admin.py b/api/admin.py index 9b57b651..a5b32506 100644 --- a/api/admin.py +++ b/api/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django_admin_relation_links import AdminChangeLinksMixin from django.contrib.auth.models import Group, User from django.contrib.auth.admin import UserAdmin -from .models import Order, LNPayment, Profile +from .models import Order, LNPayment, Profile, MarketTick admin.site.unregister(Group) admin.site.unregister(User) @@ -17,8 +17,8 @@ class ProfileInline(admin.StackedInline): @admin.register(User) class EUserAdmin(UserAdmin): inlines = [ProfileInline] - list_display = ('avatar_tag',) + UserAdmin.list_display - list_display_links = ['username'] + list_display = ('avatar_tag','id','username','last_login','date_joined','is_staff') + list_display_links = ('id','username') def avatar_tag(self, obj): return obj.profile.avatar_tag() @@ -27,16 +27,24 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): list_display = ('id','type','maker_link','taker_link','status','amount','currency','t0_satoshis','is_disputed','is_fiat_sent','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link') list_display_links = ('id','type') change_links = ('maker','taker','buyer_invoice','maker_bond','taker_invoice','taker_bond','trade_escrow') + list_filter = ('is_disputed','is_fiat_sent','type','currency','status') @admin.register(LNPayment) class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin): list_display = ('id','concept','status','num_satoshis','type','invoice','expires_at','sender_link','receiver_link') list_display_links = ('id','concept') change_links = ('sender','receiver') + list_filter = ('type','concept','status') @admin.register(Profile) class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin): list_display = ('avatar_tag','id','user_link','total_ratings','avg_rating','num_disputes','lost_disputes') list_display_links = ('avatar_tag','id') change_links =['user'] - readonly_fields = ['avatar_tag'] \ No newline at end of file + readonly_fields = ['avatar_tag'] + +@admin.register(MarketTick) +class MarketTickAdmin(admin.ModelAdmin): + list_display = ('timestamp','price','volume','premium','currency','fee') + readonly_fields = ('timestamp','price','volume','premium','currency','fee') + list_filter = ['currency'] \ No newline at end of file diff --git a/api/logics.py b/api/logics.py index 19dd971b..140f8ae1 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1,10 +1,10 @@ from datetime import timedelta from django.utils import timezone -import requests from .lightning import LNNode -from .models import Order, LNPayment, User +from .models import Order, LNPayment, MarketTick, User from decouple import config +from .utils import get_exchange_rate FEE = float(config('FEE')) BOND_SIZE = float(config('BOND_SIZE')) @@ -61,11 +61,9 @@ class Logics(): if order.is_explicit: 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 + exchange_rate = get_exchange_rate(Order.Currencies(order.currency).label) + premium_rate = exchange_rate * (1+float(order.premium)/100) + satoshis_now = (float(order.amount) / premium_rate) * 100*1000*1000 return int(satoshis_now) @@ -306,7 +304,7 @@ class Logics(): order.save() return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis} - + def settle_escrow(order): ''' Settles the trade escrow HTLC''' # TODO ERROR HANDLING diff --git a/api/models.py b/api/models.py index 054381c9..d69cd767 100644 --- a/api/models.py +++ b/api/models.py @@ -7,18 +7,14 @@ from django.utils.html import mark_safe from decouple import config from pathlib import Path +from .utils import get_exchange_rate -############################# -# TODO -# Load hparams from .env file MIN_TRADE = int(config('MIN_TRADE')) MAX_TRADE = int(config('MAX_TRADE')) FEE = float(config('FEE')) BOND_SIZE = float(config('BOND_SIZE')) - - class LNPayment(models.Model): class Types(models.IntegerChoices): @@ -192,3 +188,48 @@ class Profile(models.Model): 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 prices for nice + insight on the historical premium of Non-KYC BTC + + Price is set when both taker bond is locked. Both + maker and taker are commited with bonds (contract + is finished and cancellation has a cost) + ''' + + price = models.DecimalField(max_digits=10, 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.PositiveSmallIntegerField(choices=Order.Currencies.choices, null=True) + timestamp = models.DateTimeField(auto_now_add=True) + + # 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): + ''' + Adds 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 + premium = 100 * (price / get_exchange_rate(Order.Currencies(order.currency).label) - 1) + + tick = MarketTick.objects.create( + price=price, + volume=volume, + premium=premium, + currency=order.currency) + tick.save() + + def __str__(self): + return f'Tick: {self.id}' + + diff --git a/api/utils.py b/api/utils.py new file mode 100644 index 00000000..0b1b934b --- /dev/null +++ b/api/utils.py @@ -0,0 +1,12 @@ + +from decouple import config +import requests + +def get_exchange_rate(currency): + # 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(config('MARKET_PRICE_API')).json() + exchange_rate = float(market_prices[currency]['last']) + + return exchange_rate \ No newline at end of file diff --git a/api/views.py b/api/views.py index 8ac2fd3e..d8ccf874 100644 --- a/api/views.py +++ b/api/views.py @@ -322,23 +322,27 @@ class UserView(APIView): # It is unlikely, but maybe the nickname is taken (1 in 20 Billion change) context['found'] = 'Bad luck, this nickname is taken' context['bad_request'] = 'Enter a different token' - return Response(context, status=status.HTTP_403_FORBIDDEN) + return Response(context, status.HTTP_403_FORBIDDEN) def delete(self,request): - user = User.objects.get(id = request.user.id) + ''' Pressing "give me another" deletes the logged in user ''' + user = request.user + if not user: + return Response(status.HTTP_403_FORBIDDEN) - # TO DO. Pressing "give me another" deletes the logged in user - # However it might be a long time recovered user - # Only delete if user live is < 5 minutes + # Only delete if user life is shorter than 30 minutes. Helps deleting users by mistake + if user.date_joined < (timezone.now() - timedelta(minutes=30)): + return Response(status.HTTP_400_BAD_REQUEST) - # TODO check if user exists AND it is not a maker or taker! - if user is not None: - logout(request) - user.delete() + # Check if it is not a maker or taker! + if not Logics.validate_already_maker_or_taker(user): + return Response({'bad_request':'User cannot be deleted while he is part of an order'}, status.HTTP_400_BAD_REQUEST) - return Response({'user_deleted':'User deleted permanently'},status=status.HTTP_302_FOUND) + logout(request) + user.delete() + return Response({'user_deleted':'User deleted permanently'}, status.HTTP_301_MOVED_PERMANENTLY) - return Response(status=status.HTTP_403_FORBIDDEN) + class BookView(ListAPIView): serializer_class = ListOrderSerializer @@ -367,14 +371,16 @@ class BookView(ListAPIView): class InfoView(ListAPIView): - def get(self, request, format = None): + def get(self): 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.BUY, status=Order.Status.PUB)) - context['num_active_robots'] = None # Todo + context['last_day_avg_btc_premium'] = None # Todo + context['num_active_robots'] = None context['total_volume'] = None - return Response(context, status.HTTP_200_ok) + return Response(context, status.HTTP_200_OK)