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/admin.py b/api/admin.py index dd761dd2..b45ca85b 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin -from django.db import models +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, Profile +from .models import Order, LNPayment, Profile admin.site.unregister(Group) admin.site.unregister(User) @@ -23,14 +23,20 @@ class EUserAdmin(UserAdmin): return obj.profile.avatar_tag() @admin.register(Order) -class OrderAdmin(admin.ModelAdmin): - list_display = ('id','type','maker','taker','status','amount','currency','created_at','expires_at') - list_display_links = ('maker','taker') - pass +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_links = ('id','type') + change_links = ('maker','taker','buyer_invoice','maker_bond','taker_invoice','taker_bond','trade_escrow') + +@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') @admin.register(Profile) -class UserProfileAdmin(admin.ModelAdmin): - list_display = ('avatar_tag','user','id','total_ratings','avg_rating','num_disputes','lost_disputes') - list_display_links =['user'] - readonly_fields = ['avatar_tag'] - pass \ No newline at end of file +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 diff --git a/api/lightning.py b/api/lightning.py new file mode 100644 index 00000000..cf66a12f --- /dev/null +++ b/api/lightning.py @@ -0,0 +1,55 @@ +from datetime import timedelta +from django.utils import timezone + +import random +import string + +####### +# Placeholder functions +# Should work with LND (maybe c-lightning in the future) + +class LNNode(): + ''' + Place holder functions to interact with Lightning Node + ''' + + 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 + 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''' + 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''' + return True + + def charge_hodl_htlcs(invoice): + '''Charges a LN hodl invoice''' + return True + + def free_hodl_htlcs(invoice): + '''Returns sats''' + return True + + + + + + diff --git a/api/logics.py b/api/logics.py new file mode 100644 index 00000000..a0f6c62a --- /dev/null +++ b/api/logics.py @@ -0,0 +1,294 @@ +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') + +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')) + +BOND_EXPIRY = int(config('BOND_EXPIRY')) +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) + 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 validate_order_size(order): + '''Checks if order is withing limits at t0''' + if order.t0_satoshis > 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': 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): + 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: + # 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 int(satoshis_now) + + def order_expires(order): + order.status = Order.Status.EXP + order.maker = None + 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) + # 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...'} + + 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} + ) + + # 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 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 + '''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() ''' + + # 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 (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. + Has a small cost for both parties to prevent node DDOS.''' + + else: + return False, {'bad_request':'You cannot cancel this order'} + + @classmethod + def gen_maker_hodl_invoice(cls, order, user): + + # 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, {'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 and is still unpaid + if order.maker_bond: + 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.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 + 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, + 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, {'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 X minutes and unpaid still + if order.taker_bond: + # 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 False, None + + 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 + invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600) + + order.taker_bond = LNPayment.objects.create( + concept = LNPayment.Concepts.TAKEBOND, + 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, {'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 150c4c35..accc8e18 100644 --- a/api/models.py +++ b/api/models.py @@ -3,16 +3,63 @@ 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 decouple import config from pathlib import Path ############################# # TODO # Load hparams from .env file -min_satoshis_trade = 10*1000 -max_satoshis_trade = 500*1000 + +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): + NORM = 0, 'Regular invoice' # Only outgoing HTLCs will be regular invoices (Non-hodl) + HODL = 1, 'Hodl invoice' + + class Concepts(models.IntegerChoices): + MAKEBOND = 0, 'Maker bond' + TAKEBOND = 1, 'Taker bond' + TRESCROW = 2, 'Trade escrow' + PAYBUYER = 3, 'Payment to buyer' + + class Status(models.IntegerChoices): + 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) + 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 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) + 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() + num_satoshis = models.PositiveBigIntegerField(validators=[MinValueValidator(MIN_TRADE*BOND_SIZE), MaxValueValidator(MAX_TRADE*(1+BOND_SIZE+FEE))]) + + # 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) + + def __str__(self): + return (f'HTLC {self.id}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}') class Order(models.Model): @@ -25,30 +72,29 @@ class Order(models.Model): EUR = 2, 'EUR' ETH = 3, 'ETH' - class Status(models.TextChoices): - WFB = 0, 'Waiting for bond' - PUB = 1, 'Published in order book' - DEL = 2, 'Deleted from order book' - TAK = 3, 'Taken' - 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' + 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' + 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, id = models.CharField(max_length=64, unique=True, null=False) - status = models.PositiveSmallIntegerField(choices=Status.choices, default=Status.WFB) + # order info + status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.WFB) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField() @@ -56,42 +102,60 @@ 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") - premium = models.DecimalField(max_digits=5, decimal_places=2, default=0, null=True, validators=[MinValueValidator(-100), MaxValueValidator(999)]) - satoshis = models.PositiveBigIntegerField(null=True, validators=[MinValueValidator(min_satoshis_trade), MaxValueValidator(max_satoshis_trade)]) - is_explicit = models.BooleanField(default=False, null=False) # pricing method. A explicit amount of sats, or a relative premium above/below market. + payment_method = models.CharField(max_length=30, null=False, default="not specified", blank=True) + # order pricing method. A explicit amount of sats, or a relative premium above/below market. + is_explicit = models.BooleanField(default=False, null=False) + # marked to market + premium = models.DecimalField(max_digits=5, decimal_places=2, default=0, null=True, validators=[MinValueValidator(-100), MaxValueValidator(999)], blank=True) + # explicit + satoshis = models.PositiveBigIntegerField(null=True, validators=[MinValueValidator(MIN_TRADE), MaxValueValidator(MAX_TRADE)], blank=True) + # 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 - taker = models.ForeignKey(User, related_name='taker', on_delete=models.SET_NULL, null=True, default=None) # unique = True, a taker can only take one order - - # order collateral - has_maker_bond = models.BooleanField(default=False, null=False) - has_taker_bond = models.BooleanField(default=False, null=False) - has_trade_collat = models.BooleanField(default=False, null=False) + 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. - maker_bond_secret = models.CharField(max_length=300, unique=False, null=True, default=None) - taker_bond_secret = models.CharField(max_length=300, unique=False, null=True, default=None) - trade_collat_secret = models.CharField(max_length=300, unique=False, null=True, default=None) + # 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) # buyer payment LN invoice - has_invoice = models.BooleanField(default=False, null=False) # has invoice and is valid - invoice = models.CharField(max_length=300, unique=False, null=True, default=None) + 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} BTC for {self.amount} {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) + + for htlc in to_delete: + try: + htlc.delete() + except: + pass class Profile(models.Model): + user = models.OneToOneField(User,on_delete=models.CASCADE) # Ratings stored as a comma separated integer list total_ratings = models.PositiveIntegerField(null=False, default=0) - latest_ratings = models.CharField(max_length=999, null=True, default=None, validators=[validate_comma_separated_integer_list]) # Will only store latest ratings - avg_rating = models.DecimalField(max_digits=4, decimal_places=1, default=None, null=True, validators=[MinValueValidator(0), MaxValueValidator(100)]) + latest_ratings = models.CharField(max_length=999, null=True, default=None, validators=[validate_comma_separated_integer_list], blank=True) # Will only store latest ratings + avg_rating = models.DecimalField(max_digits=4, decimal_places=1, default=None, null=True, validators=[MinValueValidator(0), MaxValueValidator(100)], blank=True) # Disputes num_disputes = models.PositiveIntegerField(null=False, default=0) lost_disputes = models.PositiveIntegerField(null=False, default=0) # RoboHash - avatar = models.ImageField(default="static/assets/avatars/unknown.png", verbose_name='Avatar') + avatar = models.ImageField(default="static/assets/misc/unknown_avatar.png", verbose_name='Avatar', blank=True) @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): @@ -102,15 +166,21 @@ class Profile(models.Model): def save_user_profile(sender, instance, **kwargs): instance.profile.save() + @receiver(pre_delete, sender=User) + def del_avatar_from_disk(sender, instance, **kwargs): + avatar_file=Path('frontend/' + instance.profile.avatar.url) + avatar_file.unlink() # FIX deleting user fails if avatar is not found + def __str__(self): return self.user.username # to display avatars in admin panel def get_avatar(self): if not self.avatar: - return 'static/assets/avatars/unknown.png' + return 'static/assets/misc/unknown_avatar.png' return self.avatar.url # method to create a fake table field in read only mode def avatar_tag(self): return mark_safe('' % self.get_avatar()) + diff --git a/api/serializers.py b/api/serializers.py index b73cb157..a819db91 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from .models import Order -class OrderSerializer(serializers.ModelSerializer): +class ListOrderSerializer(serializers.ModelSerializer): class Meta: model = Order fields = ('id','status','created_at','expires_at','type','currency','amount','payment_method','is_explicit','premium','satoshis','maker','taker') @@ -9,4 +9,9 @@ class OrderSerializer(serializers.ModelSerializer): class MakeOrderSerializer(serializers.ModelSerializer): class Meta: model = Order - fields = ('type','currency','amount','payment_method','is_explicit','premium','satoshis') \ No newline at end of file + fields = ('type','currency','amount','payment_method','is_explicit','premium','satoshis') + +class UpdateOrderSerializer(serializers.Serializer): + invoice = serializers.CharField(max_length=300, allow_null=True, allow_blank=True, default=None) + 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/urls.py b/api/urls.py index 1af71120..b10893c8 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,9 +1,9 @@ from django.urls import path -from .views import MakeOrder, OrderView, UserGenerator, BookView +from .views import OrderMakerView, OrderView, UserView, BookView urlpatterns = [ - path('make/', MakeOrder.as_view()), - path('order/', OrderView.as_view()), - path('usergen/', UserGenerator.as_view()), + path('make/', OrderMakerView.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()), ] \ No newline at end of file diff --git a/api/views.py b/api/views.py index e7accd36..c1cf3897 100644 --- a/api/views.py +++ b/api/views.py @@ -1,12 +1,14 @@ -from rest_framework import serializers, status +from rest_framework import status, viewsets +from rest_framework.generics import CreateAPIView, ListAPIView from rest_framework.views import APIView from rest_framework.response import Response + from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import User -from django.conf.urls.static import static -from .serializers import OrderSerializer, MakeOrderSerializer -from .models import Order +from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer +from .models import LNPayment, Order +from .logics import Logics from .nick_generator.nick_generator import NickGenerator from robohash import Robohash @@ -17,94 +19,212 @@ import hashlib from pathlib import Path from datetime import timedelta from django.utils import timezone +from decouple import config -# .env -expiration_time = 8 +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) # Create your views here. -class MakeOrder(APIView): +class OrderMakerView(CreateAPIView): serializer_class = MakeOrderSerializer 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) - # query if the user is already a maker or taker, return error - queryset = Order.objects.filter(maker=request.user.id) - if queryset.exists(): - return 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 Response({'Bad Request':'You are already taker of an order'},status=status.HTTP_400_BAD_REQUEST) + 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, - currency=currency, - amount=amount, - payment_method=payment_method, - premium=premium, - satoshis=satoshis, - is_explicit=is_explicit, - expires_at= timezone.now()+timedelta(hours=expiration_time), - maker=request.user) - order.save() + valid, context = Logics.validate_already_maker_or_taker(request.user) + if not valid: return Response(context, status.HTTP_409_CONFLICT) - if not serializer.is_valid(): - return Response(status=status.HTTP_400_BAD_REQUEST) - - return Response(OrderSerializer(order).data, status=status.HTTP_201_CREATED) + # 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), # 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) + + valid, context = Logics.validate_order_size(order) + if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) + + order.save() + return Response(ListOrderSerializer(order).data, status=status.HTTP_201_CREATED) -class OrderView(APIView): - serializer_class = OrderSerializer +class OrderView(viewsets.ViewSet): + serializer_class = UpdateOrderSerializer 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: - 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 = self.serializer_class(order).data - nickname = request.user.username + # check if exactly one order is found in the db + if len(order) != 1 : + return Response({'bad_request':'Invalid Order Id'}, status.HTTP_404_NOT_FOUND) + + # This is our order. + order = order[0] - # Check if requester is participant in the order and add boolean to response - data['is_participant'] = (str(order.maker) == nickname or str(order.taker) == nickname) - - #To do fix: data['status_message'] = Order.Status.get(order.status).label - data['status_message'] = Order.Status.WFB.label # Hardcoded WFB, should use order.status value. + # 1) If order expired + if order.status == Order.Status.EXP: + return Response({'bad_request':'This order has expired'},status.HTTP_400_BAD_REQUEST) - data['maker_nick'] = str(order.maker) - data['taker_nick'] = str(order.taker) + # 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) - if data['is_participant']: - return Response(data, status=status.HTTP_200_OK) - else: - # Non participants should not see the status or who is the taker - data.pop('status','status_message','taker','taker_nick') - return Response(data, status=status.HTTP_200_OK) + data = ListOrderSerializer(order).data - return Response({'Order Not Found':'Invalid Order Id'},status=status.HTTP_404_NOT_FOUND) + # 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) - 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 -class UserGenerator(APIView): + # 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): + + # 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): + ''' + 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) + if not serializer.is_valid(): return Response(status=status.HTTP_400_BAD_REQUEST) + + order = Order.objects.get(id=order_id) + + # action is either 1)'take', 2)'confirm', 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_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': + valid, context = Logics.cancel_order(order,request.user) + if not valid: return Response(context,status.HTTP_400_BAD_REQUEST) + + # 4) If action is confirm + elif action == 'confirm': + pass + + # 5) If action is dispute + elif action == 'dispute': + pass + + # 6) If action is dispute + 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 nothing... 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 self.get(request) + +class UserView(APIView): lookup_url_kwarg = 'token' NickGen = NickGenerator( lang='English', @@ -113,6 +233,7 @@ class UserGenerator(APIView): use_noun=True, max_num=999) + # Probably should be turned into a post method def get(self,request, format=None): ''' Get a new user derived from a high entropy token @@ -128,8 +249,7 @@ class UserGenerator(APIView): value, counts = np.unique(list(token), return_counts=True) shannon_entropy = entropy(counts, base=62) bits_entropy = log2(len(value)**len(token)) - - # Start preparing payload + # Payload context = {'token_shannon_entropy': shannon_entropy, 'token_bits_entropy': bits_entropy} # Deny user gen if entropy below 128 bits or 0.7 shannon heterogeneity @@ -140,11 +260,11 @@ class UserGenerator(APIView): # Hashes the token, only 1 iteration. Maybe more is better. hash = hashlib.sha256(str.encode(token)).hexdigest() - # generate nickname + # Generate nickname nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0] context['nickname'] = nickname - # generate avatar + # Generate avatar rh = Robohash(hash) rh.assemble(roboset='set1', bgset='any')# for backgrounds ON @@ -155,7 +275,7 @@ class UserGenerator(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) @@ -180,40 +300,40 @@ class UserGenerator(APIView): def delete(self,request): user = User.objects.get(id = request.user.id) - # TO DO. Pressing give me another will delete the logged in user + # 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 # TODO check if user exists AND it is not a maker or taker! if user is not None: - avatar_file = avatar_path.joinpath(str(request.user)+".png") - avatar_file.unlink() # Unsafe if avatar does not exist. logout(request) user.delete() - return Response({'user_deleted':'User deleted permanently'},status=status.HTTP_301_MOVED_PERMANENTLY) + return Response({'user_deleted':'User deleted permanently'},status=status.HTTP_302_FOUND) return Response(status=status.HTTP_403_FORBIDDEN) -class BookView(APIView): - serializer_class = OrderSerializer +class BookView(ListAPIView): + serializer_class = ListOrderSerializer def get(self,request, format=None): currency = request.GET.get('currency') type = request.GET.get('type') - queryset = Order.objects.filter(currency=currency, type=type, status=0) # TODO status = 1 for orders that are Public + queryset = Order.objects.filter(currency=currency, type=type, status=int(Order.Status.PUB)) if len(queryset)== 0: return Response({'not_found':'No orders found, be the first to make one'}, status=status.HTTP_404_NOT_FOUND) queryset = queryset.order_by('created_at') book_data = [] for order in queryset: - data = OrderSerializer(order).data + data = ListOrderSerializer(order).data user = User.objects.filter(id=data['maker']) if len(user) == 1: data['maker_nick'] = user[0].username - # TODO avoid sending status and takers for book views - #data.pop('status','taker') + + # Non participants should not see the status or who is the taker + for key in ('status','taker'): + del data[key] book_data.append(data) return Response(book_data, status=status.HTTP_200_OK) diff --git a/dev_utils/reinitiate_db.sh b/dev_utils/reinitiate_db.sh new file mode 100644 index 00000000..32d63e95 --- /dev/null +++ b/dev_utils/reinitiate_db.sh @@ -0,0 +1,15 @@ +#!/bin/bash +rm db.sqlite3 + +rm -R api/migrations +rm -R frontend/migrations +rm -R frontend/static/assets/avatars + +python3 manage.py makemigrations +python3 manage.py makemigrations api + +python3 manage.py migrate + +python3 manage.py createsuperuser + +python3 manage.py runserver \ No newline at end of file diff --git a/frontend/src/components/BookPage.js b/frontend/src/components/BookPage.js index 13f6ef3b..bdf30d83 100644 --- a/frontend/src/components/BookPage.js +++ b/frontend/src/components/BookPage.js @@ -1,5 +1,6 @@ import React, { Component } from "react"; -import { Paper, 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,18 +14,20 @@ 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) .then((response) => response.json()) .then((data) => //console.log(data)); - this.setState({orders: data})); + this.setState({ + orders: data, + not_found: data.not_found, + })); } handleCardClick=(e)=>{ - console.log(e.target) - this.props.history.push('/order/' + e.target); + console.log(e) + this.props.history.push('/order/' + e); } // Make these two functions sequential. getOrderDetails needs setState to be finish beforehand. @@ -53,6 +56,63 @@ export default class BookPage extends Component { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } + bookCards=()=>{ + return (this.state.orders.map((order) => + + + + this.handleCardClick(order.id)}> + + + + + + + + + + {order.maker_nick} + + + + + {/* CARD PARAGRAPH CONTENT */} + + + ◑{order.type == 0 ? Buys : Sells } + {parseFloat(parseFloat(order.amount).toFixed(4))} + {" " +this.getCurrencyCode(order.currency)} worth of bitcoin + + + + ◑ 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) + + + + + + + + + + )); + } + render() { return ( @@ -102,66 +162,30 @@ export default class BookPage extends Component { - {this.state.orders.map((order) => - - - - {/* To fix! does not pass order.id to handleCardCLick. Instead passes the clicked */} - - - - - - - - - - - {order.maker_nick} - - - - - {/* CARD PARAGRAPH CONTENT */} - - - ◑{order.type == 0 ? Buys : Sells } - {parseFloat(parseFloat(order.amount).toFixed(4))} - {" " +this.getCurrencyCode(order.currency)} worth of bitcoin - - - - ◑ 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) - - - - - - - - - - )} + { this.state.not_found ? "" : You are {this.state.type == 0 ? " selling " : " buying "} BTC for {this.state.currencyCode} + } + + { this.state.not_found ? + ( + + + 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() + } + + + {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 50c526fa..86ed50ec 100644 --- a/frontend/src/components/OrderPage.js +++ b/frontend/src/components/OrderPage.js @@ -2,6 +2,34 @@ import React, { Component } from "react"; import { Paper, Button , Grid, Typography, List, ListItem, ListItemText, ListItemAvatar, Avatar, Divider} from "@material-ui/core" import { Link } from 'react-router-dom' +function msToTime(duration) { + var seconds = Math.floor((duration / 1000) % 60), + minutes = Math.floor((duration / (1000 * 60)) % 60), + hours = Math.floor((duration / (1000 * 60 * 60)) % 24); + + minutes = (minutes < 10) ? "0" + minutes : minutes; + seconds = (seconds < 10) ? "0" + seconds : seconds; + + return hours + "h " + minutes + "m " + seconds + "s"; +} + +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} +const csrftoken = getCookie('csrftoken'); + // pretty numbers function pn(x) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); @@ -26,7 +54,7 @@ export default class OrderPage extends Component { statusText: data.status_message, type: data.type, currency: data.currency, - currencyCode: (data.currency== 1 ) ? "USD": ((data.currency == 2 ) ? "EUR":"ETH"), + currencyCode: this.getCurrencyCode(data.currency), amount: data.amount, paymentMethod: data.payment_method, isExplicit: data.is_explicit, @@ -37,14 +65,39 @@ export default class OrderPage extends Component { makerNick: data.maker_nick, takerId: data.taker, takerNick: data.taker_nick, + isBuyer:data.buyer, + isSeller:data.seller, + expiresAt:data.expires_at, + badRequest:data.bad_request, }); }); } + // Gets currency code (3 letters) from numeric (e.g., 1 -> USD) + // Improve this function so currencies are read from json + getCurrencyCode(val){ + return (val == 1 ) ? "USD": ((val == 2 ) ? "EUR":"ETH") + } + // Fix to use proper react props handleClickBackButton=()=>{ window.history.back(); } + + handleClickTakeOrderButton=()=>{ + console.log(this.state) + const requestOptions = { + method: 'POST', + 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()) + .then((data) => (console.log(data) & this.getOrderDetails(data.id))); + } + render (){ return ( @@ -53,17 +106,43 @@ export default class OrderPage extends Component { BTC {this.state.type ? " Sell " : " Buy "} Order - - + + - + + + {this.state.isParticipant ? + <> + {this.state.takerNick!='None' ? + <> + + + + + + + + : + "" + } + + + + + + :"" + } + @@ -80,32 +159,26 @@ export default class OrderPage extends Component { } - {this.state.isParticipant ? - <> - - - - - { this.state.takerNick!='None' ? - <> - - - : ""} - - :"" - } + + + + + + + + - {this.state.isParticipant ? "" : } + {this.state.isParticipant ? "" : } - {this.state.isParticipant ? "" : } + - + ); diff --git a/frontend/src/components/TradePipelineBox.js b/frontend/src/components/TradePipelineBox.js new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/components/UserGenPage.js b/frontend/src/components/UserGenPage.js index bdab24d0..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()) @@ -74,7 +74,7 @@ export default class UserGenPage extends Component { this.setState({ token: this.genBase62Token(32), }) - this.getGeneratedUser(); + this.reload_for_csrf_to_work(); } handleChangeToken=(e)=>{ diff --git a/frontend/static/assets/misc/unknown_avatar.png b/frontend/static/assets/misc/unknown_avatar.png new file mode 100644 index 00000000..9e19b6a9 Binary files /dev/null and b/frontend/static/assets/misc/unknown_avatar.png differ diff --git a/setup.md b/setup.md index f3b0af5d..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 @@ -28,6 +31,9 @@ source /usr/local/bin/virtualenvwrapper.sh ### Install Django and Restframework `pip3 install django djangorestframework` +## Install Django admin relational links +`pip install django-admin-relation-links` + *Django 4.0 at the time of writting* ### Launch the local development node