mirror of
https://github.com/RoboSats/robosats.git
synced 2025-02-21 12:49:02 +00:00
Merge pull request #25 from Reckless-Satoshi/logics-second-iteration
Not yet finished. Lot's of work left. Very buggy. A third iteration needed before v0.1.0 minimum viable product. Yet, it is time to start integrating the lightning services and these logics will be a nice place to start.
This commit is contained in:
commit
18686c26f4
12
.env-sample
12
.env-sample
@ -5,13 +5,21 @@ MARKET_PRICE_API = 'https://blockchain.info/ticker'
|
|||||||
FEE = 0.002
|
FEE = 0.002
|
||||||
# Bond size in percentage %
|
# Bond size in percentage %
|
||||||
BOND_SIZE = 0.01
|
BOND_SIZE = 0.01
|
||||||
|
# Time out penalty for canceling takers in MINUTES
|
||||||
|
PENALTY_TIMEOUT = 2
|
||||||
|
|
||||||
# Trade limits in satoshis
|
# Trade limits in satoshis
|
||||||
MIN_TRADE = 10000
|
MIN_TRADE = 10000
|
||||||
MAX_TRADE = 500000
|
MAX_TRADE = 500000
|
||||||
|
|
||||||
# Expiration time in minutes
|
# Expiration time for HODL invoices and returning collateral in HOURS
|
||||||
EXPIRATION_MAKE = 5
|
BOND_EXPIRY = 8
|
||||||
|
ESCROW_EXPIRY = 8
|
||||||
|
|
||||||
|
# Expiration time for locking collateral in MINUTES
|
||||||
|
EXP_MAKER_BOND_INVOICE = 300
|
||||||
|
EXP_TAKER_BOND_INVOICE = 200
|
||||||
|
EXP_TRADE_ESCR_INVOICE = 200
|
||||||
|
|
||||||
# Username for HTLCs escrows
|
# Username for HTLCs escrows
|
||||||
ESCROW_USERNAME = 'admin'
|
ESCROW_USERNAME = 'admin'
|
18
api/admin.py
18
api/admin.py
@ -2,7 +2,7 @@ from django.contrib import admin
|
|||||||
from django_admin_relation_links import AdminChangeLinksMixin
|
from django_admin_relation_links import AdminChangeLinksMixin
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.auth.admin import UserAdmin
|
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(Group)
|
||||||
admin.site.unregister(User)
|
admin.site.unregister(User)
|
||||||
@ -17,26 +17,34 @@ class ProfileInline(admin.StackedInline):
|
|||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class EUserAdmin(UserAdmin):
|
class EUserAdmin(UserAdmin):
|
||||||
inlines = [ProfileInline]
|
inlines = [ProfileInline]
|
||||||
list_display = ('avatar_tag',) + UserAdmin.list_display
|
list_display = ('avatar_tag','id','username','last_login','date_joined','is_staff')
|
||||||
list_display_links = ['username']
|
list_display_links = ('id','username')
|
||||||
def avatar_tag(self, obj):
|
def avatar_tag(self, obj):
|
||||||
return obj.profile.avatar_tag()
|
return obj.profile.avatar_tag()
|
||||||
|
|
||||||
@admin.register(Order)
|
@admin.register(Order)
|
||||||
class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
|
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')
|
list_display_links = ('id','type')
|
||||||
change_links = ('maker','taker','buyer_invoice','maker_bond','taker_invoice','taker_bond','trade_escrow')
|
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)
|
@admin.register(LNPayment)
|
||||||
class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
|
class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
|
||||||
list_display = ('id','concept','status','num_satoshis','type','invoice','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')
|
list_display_links = ('id','concept')
|
||||||
change_links = ('sender','receiver')
|
change_links = ('sender','receiver')
|
||||||
|
list_filter = ('type','concept','status')
|
||||||
|
|
||||||
@admin.register(Profile)
|
@admin.register(Profile)
|
||||||
class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
|
class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
|
||||||
list_display = ('avatar_tag','id','user_link','total_ratings','avg_rating','num_disputes','lost_disputes')
|
list_display = ('avatar_tag','id','user_link','total_ratings','avg_rating','num_disputes','lost_disputes')
|
||||||
list_display_links = ('avatar_tag','id')
|
list_display_links = ('avatar_tag','id')
|
||||||
change_links =['user']
|
change_links =['user']
|
||||||
readonly_fields = ['avatar_tag']
|
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']
|
@ -1,3 +1,6 @@
|
|||||||
|
# import codecs, grpc, os
|
||||||
|
# import lightning_pb2 as lnrpc, lightning_pb2_grpc as lightningstub
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -12,9 +15,16 @@ class LNNode():
|
|||||||
'''
|
'''
|
||||||
Place holder functions to interact with Lightning Node
|
Place holder functions to interact with Lightning Node
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
# macaroon = codecs.encode(open('LND_DIR/data/chain/bitcoin/simnet/admin.macaroon', 'rb').read(), 'hex')
|
||||||
|
# os.environ['GRPC_SSL_CIPHER_SUITES'] = 'HIGH+ECDSA'
|
||||||
|
# cert = open('LND_DIR/tls.cert', 'rb').read()
|
||||||
|
# ssl_creds = grpc.ssl_channel_credentials(cert)
|
||||||
|
# channel = grpc.secure_channel('localhost:10009', ssl_creds)
|
||||||
|
# stub = lightningstub.LightningStub(channel)
|
||||||
|
|
||||||
def gen_hodl_invoice(num_satoshis, description, expiry):
|
def gen_hold_invoice(num_satoshis, description, expiry):
|
||||||
'''Generates hodl invoice to publish an order'''
|
'''Generates hold invoice to publish an order'''
|
||||||
# TODO
|
# TODO
|
||||||
invoice = ''.join(random.choices(string.ascii_uppercase + string.digits, k=80)) #FIX
|
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
|
payment_hash = ''.join(random.choices(string.ascii_uppercase + string.digits, k=40)) #FIX
|
||||||
@ -22,32 +32,77 @@ class LNNode():
|
|||||||
|
|
||||||
return invoice, payment_hash, expires_at
|
return invoice, payment_hash, expires_at
|
||||||
|
|
||||||
def validate_hodl_invoice_locked():
|
def validate_hold_invoice_locked(payment_hash):
|
||||||
'''Generates hodl invoice to publish an order'''
|
'''Checks if hodl invoice is locked'''
|
||||||
|
|
||||||
|
# request = ln.InvoiceSubscription()
|
||||||
|
# When invoice is settled, return true. If time expires, return False.
|
||||||
|
# for invoice in stub.SubscribeInvoices(request):
|
||||||
|
# print(invoice)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_ln_invoice(invoice): # num_satoshis
|
def validate_ln_invoice(invoice, num_satoshis):
|
||||||
'''Checks if the submited LN invoice is as expected'''
|
'''Checks if the submited LN invoice is as expected'''
|
||||||
|
|
||||||
|
# request = lnrpc.PayReqString(pay_req=invoice)
|
||||||
|
# response = stub.DecodePayReq(request, metadata=[('macaroon', macaroon)])
|
||||||
|
|
||||||
|
# # {
|
||||||
|
# # "destination": <string>,
|
||||||
|
# # "payment_hash": <string>,
|
||||||
|
# # "num_satoshis": <int64>,
|
||||||
|
# # "timestamp": <int64>,
|
||||||
|
# # "expiry": <int64>,
|
||||||
|
# # "description": <string>,
|
||||||
|
# # "description_hash": <string>,
|
||||||
|
# # "fallback_addr": <string>,
|
||||||
|
# # "cltv_expiry": <int64>,
|
||||||
|
# # "route_hints": <array RouteHint>,
|
||||||
|
# # "payment_addr": <bytes>,
|
||||||
|
# # "num_msat": <int64>,
|
||||||
|
# # "features": <array FeaturesEntry>,
|
||||||
|
# # }
|
||||||
|
|
||||||
|
# if not response['num_satoshis'] == num_satoshis:
|
||||||
|
# return False, {'bad_invoice':f'The invoice provided is not for {num_satoshis}. '}, None, None, None
|
||||||
|
# description = response['description']
|
||||||
|
# payment_hash = response['payment_hash']
|
||||||
|
# expires_at = timezone(response['expiry'])
|
||||||
|
# if payment_hash and expires_at > timezone.now():
|
||||||
|
# return True, None, description, payment_hash, expires_at
|
||||||
|
|
||||||
valid = True
|
valid = True
|
||||||
num_satoshis = 50000 # TODO decrypt and confirm sats are as expected
|
context = None
|
||||||
description = 'Placeholder desc' # TODO decrypt from LN invoice
|
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
|
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):
|
def pay_invoice(invoice):
|
||||||
'''Sends sats to buyer'''
|
'''Sends sats to buyer, or cancelinvoices'''
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def charge_hodl_htlcs(invoice):
|
def check_if_hold_invoice_is_locked(payment_hash):
|
||||||
'''Charges a LN hodl invoice'''
|
'''Every hodl invoice that is in state INVGEN
|
||||||
|
Has to be checked for payment received until
|
||||||
|
the window expires'''
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def free_hodl_htlcs(invoice):
|
def settle_hold_htlcs(payment_hash):
|
||||||
|
'''Charges a LN hold invoice'''
|
||||||
|
return True
|
||||||
|
|
||||||
|
def return_hold_htlcs(payment_hash):
|
||||||
'''Returns sats'''
|
'''Returns sats'''
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def double_check_htlc_is_settled(payment_hash):
|
||||||
|
''' Just as it sounds. Better safe than sorry!'''
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
218
api/logics.py
218
api/logics.py
@ -1,15 +1,16 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import requests
|
|
||||||
from .lightning import LNNode
|
from .lightning import LNNode
|
||||||
|
|
||||||
from .models import Order, LNPayment, User
|
from .models import Order, LNPayment, MarketTick, User
|
||||||
from decouple import config
|
from decouple import config
|
||||||
|
from .utils import get_exchange_rate
|
||||||
|
|
||||||
FEE = float(config('FEE'))
|
FEE = float(config('FEE'))
|
||||||
BOND_SIZE = float(config('BOND_SIZE'))
|
BOND_SIZE = float(config('BOND_SIZE'))
|
||||||
MARKET_PRICE_API = config('MARKET_PRICE_API')
|
MARKET_PRICE_API = config('MARKET_PRICE_API')
|
||||||
ESCROW_USERNAME = config('ESCROW_USERNAME')
|
ESCROW_USERNAME = config('ESCROW_USERNAME')
|
||||||
|
PENALTY_TIMEOUT = int(config('PENALTY_TIMEOUT'))
|
||||||
|
|
||||||
MIN_TRADE = int(config('MIN_TRADE'))
|
MIN_TRADE = int(config('MIN_TRADE'))
|
||||||
MAX_TRADE = int(config('MAX_TRADE'))
|
MAX_TRADE = int(config('MAX_TRADE'))
|
||||||
@ -21,9 +22,8 @@ EXP_TRADE_ESCR_INVOICE = int(config('EXP_TRADE_ESCR_INVOICE'))
|
|||||||
BOND_EXPIRY = int(config('BOND_EXPIRY'))
|
BOND_EXPIRY = int(config('BOND_EXPIRY'))
|
||||||
ESCROW_EXPIRY = int(config('ESCROW_EXPIRY'))
|
ESCROW_EXPIRY = int(config('ESCROW_EXPIRY'))
|
||||||
|
|
||||||
class Logics():
|
|
||||||
|
|
||||||
# escrow_user = User.objects.get(username=ESCROW_USERNAME)
|
class Logics():
|
||||||
|
|
||||||
def validate_already_maker_or_taker(user):
|
def validate_already_maker_or_taker(user):
|
||||||
'''Checks if the user is already partipant of an order'''
|
'''Checks if the user is already partipant of an order'''
|
||||||
@ -38,15 +38,21 @@ class Logics():
|
|||||||
def validate_order_size(order):
|
def validate_order_size(order):
|
||||||
'''Checks if order is withing limits at t0'''
|
'''Checks if order is withing limits at t0'''
|
||||||
if order.t0_satoshis > MAX_TRADE:
|
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}'}
|
return False, {'bad_request': 'Your order is too big. It is worth '+'{:,}'.format(order.t0_satoshis)+' Sats now. But limit is '+'{:,}'.format(MAX_TRADE)+ ' Sats'}
|
||||||
if order.t0_satoshis < MIN_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 False, {'bad_request': 'Your order is too small. It is worth '+'{:,}'.format(order.t0_satoshis)+' Sats now. But limit is '+'{:,}'.format(MIN_TRADE)+ ' Sats'}
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
def take(order, user):
|
@classmethod
|
||||||
order.taker = user
|
def take(cls, order, user):
|
||||||
order.status = Order.Status.TAK
|
is_penalized, time_out = cls.is_penalized(user)
|
||||||
order.save()
|
if is_penalized:
|
||||||
|
return False, {'bad_request',f'You need to wait {time_out} seconds to take an order'}
|
||||||
|
else:
|
||||||
|
order.taker = user
|
||||||
|
order.status = Order.Status.TAK
|
||||||
|
order.save()
|
||||||
|
return True, None
|
||||||
|
|
||||||
def is_buyer(order, user):
|
def is_buyer(order, user):
|
||||||
is_maker = order.maker == user
|
is_maker = order.maker == user
|
||||||
@ -63,14 +69,27 @@ class Logics():
|
|||||||
if order.is_explicit:
|
if order.is_explicit:
|
||||||
satoshis_now = order.satoshis
|
satoshis_now = order.satoshis
|
||||||
else:
|
else:
|
||||||
# TODO Add fallback Public APIs and error handling
|
exchange_rate = get_exchange_rate(Order.currency_dict[str(order.currency)])
|
||||||
# Think about polling price data in a different way (e.g. store locally every t seconds)
|
premium_rate = exchange_rate * (1+float(order.premium)/100)
|
||||||
market_prices = requests.get(MARKET_PRICE_API).json()
|
satoshis_now = (float(order.amount) / premium_rate) * 100*1000*1000
|
||||||
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)
|
return int(satoshis_now)
|
||||||
|
|
||||||
|
def price_and_premium_now(order):
|
||||||
|
''' computes order premium live '''
|
||||||
|
exchange_rate = get_exchange_rate(Order.currency_dict[str(order.currency)])
|
||||||
|
if not order.is_explicit:
|
||||||
|
premium = order.premium
|
||||||
|
price = exchange_rate
|
||||||
|
else:
|
||||||
|
exchange_rate = get_exchange_rate(Order.currency_dict[str(order.currency)])
|
||||||
|
order_rate = float(order.amount) / (float(order.satoshis) / 100000000)
|
||||||
|
premium = order_rate / exchange_rate - 1
|
||||||
|
price = order_rate
|
||||||
|
|
||||||
|
premium = int(premium*100) # 2 decimals left
|
||||||
|
return price, premium
|
||||||
|
|
||||||
def order_expires(order):
|
def order_expires(order):
|
||||||
order.status = Order.Status.EXP
|
order.status = Order.Status.EXP
|
||||||
order.maker = None
|
order.maker = None
|
||||||
@ -89,10 +108,19 @@ class Logics():
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_invoice(cls, order, user, invoice):
|
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
|
# only the buyer can post a buyer invoice
|
||||||
if not (cls.is_buyer(order, user) or is_valid_invoice):
|
if not cls.is_buyer(order, user):
|
||||||
return False, {'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'}
|
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(
|
order.buyer_invoice, _ = LNPayment.objects.update_or_create(
|
||||||
concept = LNPayment.Concepts.PAYBUYER,
|
concept = LNPayment.Concepts.PAYBUYER,
|
||||||
@ -121,31 +149,46 @@ class Logics():
|
|||||||
else:
|
else:
|
||||||
order.status = Order.Status.WFE
|
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()
|
order.save()
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def rate_counterparty(cls, order, user, rating):
|
def rate_counterparty(cls, order, user, rating):
|
||||||
# if maker, rates taker
|
|
||||||
if order.maker == user:
|
# If the trade is finished
|
||||||
order.taker.profile.total_ratings = order.taker.profile.total_ratings + 1
|
if order.status > Order.Status.PAY:
|
||||||
last_ratings = list(order.taker.profile.last_ratings).append(rating)
|
|
||||||
order.taker.profile.total_ratings = sum(last_ratings) / len(last_ratings)
|
# if maker, rates taker
|
||||||
# if taker, rates maker
|
if order.maker == user:
|
||||||
if order.taker == user:
|
order.taker.profile.total_ratings = order.taker.profile.total_ratings + 1
|
||||||
order.maker.profile.total_ratings = order.maker.profile.total_ratings + 1
|
last_ratings = list(order.taker.profile.last_ratings).append(rating)
|
||||||
last_ratings = list(order.maker.profile.last_ratings).append(rating)
|
order.taker.profile.total_ratings = sum(last_ratings) / len(last_ratings)
|
||||||
order.maker.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()
|
order.save()
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
|
def is_penalized(user):
|
||||||
|
''' Checks if a user that is not participant of orders
|
||||||
|
has a limit on taking or making a order'''
|
||||||
|
|
||||||
|
if user.profile.penalty_expiration:
|
||||||
|
if user.profile.penalty_expiration > timezone.now():
|
||||||
|
time_out = (user.profile.penalty_expiration - timezone.now()).seconds
|
||||||
|
return True, time_out
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def cancel_order(cls, order, user, state):
|
def cancel_order(cls, order, user, state=None):
|
||||||
|
|
||||||
# 1) When maker cancels before bond
|
# 1) When maker cancels before bond
|
||||||
'''The order never shows up on the book and order
|
'''The order never shows up on the book and order
|
||||||
@ -158,12 +201,24 @@ class Logics():
|
|||||||
|
|
||||||
# 2) When maker cancels after bond
|
# 2) When maker cancels after bond
|
||||||
'''The order dissapears from book and goes to cancelled.
|
'''The order dissapears from book and goes to cancelled.
|
||||||
Maker is charged a small amount of sats, to prevent DDOS
|
Maker is charged the bond to prevent DDOS
|
||||||
on the LN node and order book'''
|
on the LN node and order book. TODO Only charge a small part
|
||||||
|
of the bond (requires maker submitting an invoice)'''
|
||||||
|
|
||||||
|
|
||||||
# 3) When taker cancels before bond
|
# 3) When taker cancels before bond
|
||||||
''' The order goes back to the book as public.
|
''' The order goes back to the book as public.
|
||||||
LNPayment "order.taker_bond" is deleted() '''
|
LNPayment "order.taker_bond" is deleted() '''
|
||||||
|
elif order.status == Order.Status.TAK and order.taker == user:
|
||||||
|
# adds a timeout penalty
|
||||||
|
user.profile.penalty_expiration = timezone.now() + timedelta(seconds=PENALTY_TIMEOUT)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
order.taker = None
|
||||||
|
order.status = Order.Status.PUB
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
# 4) When taker or maker cancel after bond (before escrow)
|
# 4) When taker or maker cancel after bond (before escrow)
|
||||||
'''The order goes into cancelled status if maker cancels.
|
'''The order goes into cancelled status if maker cancels.
|
||||||
@ -180,7 +235,7 @@ class Logics():
|
|||||||
return False, {'bad_request':'You cannot cancel this order'}
|
return False, {'bad_request':'You cannot cancel this order'}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def gen_maker_hodl_invoice(cls, order, user):
|
def gen_maker_hold_invoice(cls, order, user):
|
||||||
|
|
||||||
# Do not gen and cancel 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():
|
if order.expires_at < timezone.now():
|
||||||
@ -198,12 +253,12 @@ class Logics():
|
|||||||
bond_satoshis = int(order.last_satoshis * BOND_SIZE)
|
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.'
|
description = f'RoboSats - Publishing {str(order)} - This bond will return to you if you do not cheat.'
|
||||||
|
|
||||||
# Gen HODL Invoice
|
# Gen hold Invoice
|
||||||
invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
|
invoice, payment_hash, expires_at = LNNode.gen_hold_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
|
||||||
|
|
||||||
order.maker_bond = LNPayment.objects.create(
|
order.maker_bond = LNPayment.objects.create(
|
||||||
concept = LNPayment.Concepts.MAKEBOND,
|
concept = LNPayment.Concepts.MAKEBOND,
|
||||||
type = LNPayment.Types.HODL,
|
type = LNPayment.Types.hold,
|
||||||
sender = user,
|
sender = user,
|
||||||
receiver = User.objects.get(username=ESCROW_USERNAME),
|
receiver = User.objects.get(username=ESCROW_USERNAME),
|
||||||
invoice = invoice,
|
invoice = invoice,
|
||||||
@ -217,7 +272,7 @@ class Logics():
|
|||||||
return True, {'bond_invoice':invoice,'bond_satoshis':bond_satoshis}
|
return True, {'bond_invoice':invoice,'bond_satoshis':bond_satoshis}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def gen_taker_hodl_invoice(cls, order, user):
|
def gen_taker_hold_invoice(cls, order, user):
|
||||||
|
|
||||||
# Do not gen and cancel if a taker invoice is there and older than X minutes and unpaid still
|
# 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:
|
||||||
@ -237,12 +292,12 @@ class Logics():
|
|||||||
bond_satoshis = int(order.last_satoshis * BOND_SIZE)
|
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.'
|
description = f'RoboSats - Taking {str(order)} - This bond will return to you if you do not cheat.'
|
||||||
|
|
||||||
# Gen HODL Invoice
|
# Gen hold Invoice
|
||||||
invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
|
invoice, payment_hash, expires_at = LNNode.gen_hold_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
|
||||||
|
|
||||||
order.taker_bond = LNPayment.objects.create(
|
order.taker_bond = LNPayment.objects.create(
|
||||||
concept = LNPayment.Concepts.TAKEBOND,
|
concept = LNPayment.Concepts.TAKEBOND,
|
||||||
type = LNPayment.Types.HODL,
|
type = LNPayment.Types.hold,
|
||||||
sender = user,
|
sender = user,
|
||||||
receiver = User.objects.get(username=ESCROW_USERNAME),
|
receiver = User.objects.get(username=ESCROW_USERNAME),
|
||||||
invoice = invoice,
|
invoice = invoice,
|
||||||
@ -252,22 +307,24 @@ class Logics():
|
|||||||
payment_hash = payment_hash,
|
payment_hash = payment_hash,
|
||||||
expires_at = expires_at)
|
expires_at = expires_at)
|
||||||
|
|
||||||
|
# Extend expiry time to allow for escrow deposit
|
||||||
|
## Not here, on func for confirming taker collar. order.expires_at = timezone.now() + timedelta(minutes=EXP_TRADE_ESCR_INVOICE)
|
||||||
|
|
||||||
order.save()
|
order.save()
|
||||||
return True, {'bond_invoice':invoice,'bond_satoshis': bond_satoshis}
|
return True, {'bond_invoice':invoice,'bond_satoshis': bond_satoshis}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def gen_escrow_hodl_invoice(cls, order, user):
|
def gen_escrow_hold_invoice(cls, order, user):
|
||||||
|
|
||||||
# Do not generate and cancel if an invoice is there and older than X minutes and unpaid still
|
# Do not generate and cancel if an invoice is there and older than X minutes and unpaid still
|
||||||
if order.trade_escrow:
|
if order.trade_escrow:
|
||||||
# Check if status is INVGEN and still not expired
|
# Check if status is INVGEN and still not expired
|
||||||
if order.taker_bond.status == LNPayment.Status.INVGEN:
|
if order.trade_escrow.status == LNPayment.Status.INVGEN:
|
||||||
if order.taker_bond.created_at > (timezone.now()+timedelta(minutes=EXP_TRADE_ESCR_INVOICE)): # Expired
|
if order.trade_escrow.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
|
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 False, {'bad_request':'Invoice expired. You did not lock the trade escrow in time.'}
|
||||||
# Return the previous invoice there was with INVGEN status
|
# Return the previous invoice there was with INVGEN status
|
||||||
else:
|
else:
|
||||||
return True, {'escrow_invoice':order.trade_escrow.invoice,'escrow_satoshis':order.trade_escrow.num_satoshis}
|
return True, {'escrow_invoice': order.trade_escrow.invoice, 'escrow_satoshis':order.trade_escrow.num_satoshis}
|
||||||
# Invoice exists, but was already locked or settled
|
# Invoice exists, but was already locked or settled
|
||||||
else:
|
else:
|
||||||
return False, None # Does not return any context of a healthy locked escrow
|
return False, None # Does not return any context of a healthy locked escrow
|
||||||
@ -275,12 +332,12 @@ class Logics():
|
|||||||
escrow_satoshis = order.last_satoshis # Trade sats amount was fixed at the time of taker bond generation (order.last_satoshis)
|
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.'
|
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
|
# Gen hold Invoice
|
||||||
invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(escrow_satoshis, description, ESCROW_EXPIRY*3600)
|
invoice, payment_hash, expires_at = LNNode.gen_hold_invoice(escrow_satoshis, description, ESCROW_EXPIRY*3600)
|
||||||
|
|
||||||
order.taker_bond = LNPayment.objects.create(
|
order.trade_escrow = LNPayment.objects.create(
|
||||||
concept = LNPayment.Concepts.TRESCROW,
|
concept = LNPayment.Concepts.TRESCROW,
|
||||||
type = LNPayment.Types.HODL,
|
type = LNPayment.Types.hold,
|
||||||
sender = user,
|
sender = user,
|
||||||
receiver = User.objects.get(username=ESCROW_USERNAME),
|
receiver = User.objects.get(username=ESCROW_USERNAME),
|
||||||
invoice = invoice,
|
invoice = invoice,
|
||||||
@ -291,4 +348,53 @@ class Logics():
|
|||||||
expires_at = expires_at)
|
expires_at = expires_at)
|
||||||
|
|
||||||
order.save()
|
order.save()
|
||||||
return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis}
|
return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis}
|
||||||
|
|
||||||
|
def settle_escrow(order):
|
||||||
|
''' Settles the trade escrow HTLC'''
|
||||||
|
# TODO ERROR HANDLING
|
||||||
|
|
||||||
|
valid = LNNode.settle_hold_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 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
|
||||||
|
|
||||||
|
# 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.'}
|
||||||
|
|
||||||
|
# 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 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 INVOICE !!!
|
||||||
|
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
|
113
api/models.py
113
api/models.py
@ -7,23 +7,19 @@ from django.utils.html import mark_safe
|
|||||||
|
|
||||||
from decouple import config
|
from decouple import config
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from .utils import get_exchange_rate
|
||||||
#############################
|
import json
|
||||||
# TODO
|
|
||||||
# Load hparams from .env file
|
|
||||||
|
|
||||||
MIN_TRADE = int(config('MIN_TRADE'))
|
MIN_TRADE = int(config('MIN_TRADE'))
|
||||||
MAX_TRADE = int(config('MAX_TRADE'))
|
MAX_TRADE = int(config('MAX_TRADE'))
|
||||||
FEE = float(config('FEE'))
|
FEE = float(config('FEE'))
|
||||||
BOND_SIZE = float(config('BOND_SIZE'))
|
BOND_SIZE = float(config('BOND_SIZE'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LNPayment(models.Model):
|
class LNPayment(models.Model):
|
||||||
|
|
||||||
class Types(models.IntegerChoices):
|
class Types(models.IntegerChoices):
|
||||||
NORM = 0, 'Regular invoice' # Only outgoing HTLCs will be regular invoices (Non-hodl)
|
NORM = 0, 'Regular invoice' # Only outgoing HTLCs will be regular invoices (Non-hold)
|
||||||
HODL = 1, 'Hodl invoice'
|
hold = 1, 'hold invoice'
|
||||||
|
|
||||||
class Concepts(models.IntegerChoices):
|
class Concepts(models.IntegerChoices):
|
||||||
MAKEBOND = 0, 'Maker bond'
|
MAKEBOND = 0, 'Maker bond'
|
||||||
@ -38,10 +34,11 @@ class LNPayment(models.Model):
|
|||||||
RETNED = 3, 'Returned'
|
RETNED = 3, 'Returned'
|
||||||
MISSNG = 4, 'Missing'
|
MISSNG = 4, 'Missing'
|
||||||
VALIDI = 5, 'Valid'
|
VALIDI = 5, 'Valid'
|
||||||
INFAIL = 6, 'Failed routing'
|
PAYING = 6, 'Paying ongoing'
|
||||||
|
FAILRO = 7, 'Failed routing'
|
||||||
|
|
||||||
# payment use details
|
# payment use details
|
||||||
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.HODL)
|
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.hold)
|
||||||
concept = models.PositiveSmallIntegerField(choices=Concepts.choices, null=False, default=Concepts.MAKEBOND)
|
concept = models.PositiveSmallIntegerField(choices=Concepts.choices, null=False, default=Concepts.MAKEBOND)
|
||||||
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.INVGEN)
|
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.INVGEN)
|
||||||
routing_retries = models.PositiveSmallIntegerField(null=False, default=0)
|
routing_retries = models.PositiveSmallIntegerField(null=False, default=0)
|
||||||
@ -67,31 +64,28 @@ class Order(models.Model):
|
|||||||
BUY = 0, 'BUY'
|
BUY = 0, 'BUY'
|
||||||
SELL = 1, 'SELL'
|
SELL = 1, 'SELL'
|
||||||
|
|
||||||
class Currencies(models.IntegerChoices):
|
|
||||||
USD = 1, 'USD'
|
|
||||||
EUR = 2, 'EUR'
|
|
||||||
ETH = 3, 'ETH'
|
|
||||||
|
|
||||||
class Status(models.IntegerChoices):
|
class Status(models.IntegerChoices):
|
||||||
WFB = 0, 'Waiting for maker bond'
|
WFB = 0, 'Waiting for maker bond'
|
||||||
PUB = 1, 'Public'
|
PUB = 1, 'Public'
|
||||||
DEL = 2, 'Deleted'
|
DEL = 2, 'Deleted'
|
||||||
TAK = 3, 'Waiting for taker bond'
|
TAK = 3, 'Waiting for taker bond'
|
||||||
UCA = 4, 'Cancelled'
|
UCA = 4, 'Cancelled'
|
||||||
WF2 = 5, 'Waiting for trade collateral and buyer invoice'
|
EXP = 5, 'Expired'
|
||||||
WFE = 6, 'Waiting only for seller trade collateral'
|
WF2 = 6, 'Waiting for trade collateral and buyer invoice'
|
||||||
WFI = 7, 'Waiting only for buyer invoice'
|
WFE = 7, 'Waiting only for seller trade collateral'
|
||||||
CHA = 8, 'Sending fiat - In chatroom'
|
WFI = 8, 'Waiting only for buyer invoice'
|
||||||
CCA = 9, 'Collaboratively cancelled'
|
CHA = 9, 'Sending fiat - In chatroom'
|
||||||
FSE = 10, 'Fiat sent - In chatroom'
|
FSE = 10, 'Fiat sent - In chatroom'
|
||||||
FCO = 11, 'Fiat confirmed'
|
DIS = 11, 'In dispute'
|
||||||
SUC = 12, 'Sucessfully settled'
|
CCA = 12, 'Collaboratively cancelled'
|
||||||
FAI = 13, 'Failed lightning network routing'
|
PAY = 13, 'Sending satoshis to buyer'
|
||||||
UPI = 14, 'Updated invoice'
|
SUC = 14, 'Sucessfully settled'
|
||||||
DIS = 15, 'In dispute'
|
FAI = 15, 'Failed lightning network routing'
|
||||||
MLD = 16, 'Maker lost dispute'
|
MLD = 16, 'Maker lost dispute'
|
||||||
TLD = 17, 'Taker lost dispute'
|
TLD = 17, 'Taker lost dispute'
|
||||||
EXP = 18, 'Expired'
|
|
||||||
|
currency_dict = json.load(open('./frontend/static/assets/currencies.json'))
|
||||||
|
currency_choices = [(int(val), label) for val, label in list(currency_dict.items())]
|
||||||
|
|
||||||
# order info
|
# order info
|
||||||
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.WFB)
|
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.WFB)
|
||||||
@ -100,9 +94,9 @@ class Order(models.Model):
|
|||||||
|
|
||||||
# order details
|
# order details
|
||||||
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False)
|
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False)
|
||||||
currency = models.PositiveSmallIntegerField(choices=Currencies.choices, null=False)
|
currency = models.PositiveSmallIntegerField(choices=currency_choices, null=False)
|
||||||
amount = models.DecimalField(max_digits=9, decimal_places=4, validators=[MinValueValidator(0.00001)])
|
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=35, null=False, default="not specified", blank=True)
|
||||||
|
|
||||||
# order pricing method. A explicit amount of sats, or a relative premium above/below market.
|
# order pricing method. A explicit amount of sats, or a relative premium above/below market.
|
||||||
is_explicit = models.BooleanField(default=False, null=False)
|
is_explicit = models.BooleanField(default=False, null=False)
|
||||||
@ -118,8 +112,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
|
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
|
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_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)
|
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)
|
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)
|
trade_escrow = models.ForeignKey(LNPayment, related_name='trade_escrow', on_delete=models.SET_NULL, null=True, default=None, blank=True)
|
||||||
@ -127,12 +124,16 @@ class Order(models.Model):
|
|||||||
# buyer payment LN invoice
|
# buyer payment LN invoice
|
||||||
buyer_invoice = models.ForeignKey(LNPayment, related_name='buyer_invoice', on_delete=models.SET_NULL, null=True, default=None, blank=True)
|
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):
|
def __str__(self):
|
||||||
# Make relational back to ORDER
|
# 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.currency_dict[str(self.currency)]}')
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Order)
|
@receiver(pre_delete, sender=Order)
|
||||||
def delelete_HTLCs_at_order_deletion(sender, instance, **kwargs):
|
def delete_HTLCs_at_order_deletion(sender, instance, **kwargs):
|
||||||
to_delete = (instance.maker_bond, instance.buyer_invoice, instance.taker_bond, instance.trade_escrow)
|
to_delete = (instance.maker_bond, instance.buyer_invoice, instance.taker_bond, instance.trade_escrow)
|
||||||
|
|
||||||
for htlc in to_delete:
|
for htlc in to_delete:
|
||||||
@ -157,6 +158,9 @@ class Profile(models.Model):
|
|||||||
# RoboHash
|
# RoboHash
|
||||||
avatar = models.ImageField(default="static/assets/misc/unknown_avatar.png", verbose_name='Avatar', blank=True)
|
avatar = models.ImageField(default="static/assets/misc/unknown_avatar.png", verbose_name='Avatar', blank=True)
|
||||||
|
|
||||||
|
# Penalty expiration (only used then taking/cancelling repeatedly orders in the book before comitting bond)
|
||||||
|
penalty_expiration = models.DateTimeField(null=True)
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_user_profile(sender, instance, created, **kwargs):
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
@ -184,3 +188,50 @@ class Profile(models.Model):
|
|||||||
def avatar_tag(self):
|
def avatar_tag(self):
|
||||||
return mark_safe('<img src="%s" width="50" height="50" />' % self.get_avatar())
|
return mark_safe('<img src="%s" width="50" height="50" />' % 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 price for useful
|
||||||
|
insight on the historical premium of Non-KYC BTC
|
||||||
|
|
||||||
|
Price is set when 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.currency_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):
|
||||||
|
'''
|
||||||
|
Creates 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.currency_dict[str(order.currency)]) - 1)
|
||||||
|
|
||||||
|
tick = MarketTick.objects.create(
|
||||||
|
price=price,
|
||||||
|
volume=volume,
|
||||||
|
premium=premium,
|
||||||
|
currency=order.currency)
|
||||||
|
|
||||||
|
tick.save()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Tick: {self.id}'
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import OrderMakerView, OrderView, UserView, BookView
|
from .views import MakerView, OrderView, UserView, BookView, InfoView
|
||||||
|
|
||||||
urlpatterns = [
|
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('order/', OrderView.as_view({'get':'get','post':'take_update_confirm_dispute_cancel'})),
|
||||||
path('usergen/', UserView.as_view()),
|
path('user/', UserView.as_view()),
|
||||||
path('book/', BookView.as_view()),
|
path('book/', BookView.as_view()),
|
||||||
|
# path('robot/') # Profile Info
|
||||||
|
path('info/', InfoView.as_view()),
|
||||||
]
|
]
|
16
api/utils.py
Normal file
16
api/utils.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
from decouple import config
|
||||||
|
import requests
|
||||||
|
import ring
|
||||||
|
|
||||||
|
storage = {}
|
||||||
|
|
||||||
|
@ring.dict(storage, expire=30) #keeps in cache for 30 seconds
|
||||||
|
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
|
195
api/views.py
195
api/views.py
@ -1,3 +1,4 @@
|
|||||||
|
from re import T
|
||||||
from rest_framework import status, viewsets
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.generics import CreateAPIView, ListAPIView
|
from rest_framework.generics import CreateAPIView, ListAPIView
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@ -22,13 +23,14 @@ from django.utils import timezone
|
|||||||
from decouple import config
|
from decouple import config
|
||||||
|
|
||||||
EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE'))
|
EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE'))
|
||||||
|
FEE = float(config('FEE'))
|
||||||
|
|
||||||
avatar_path = Path('frontend/static/assets/avatars')
|
avatar_path = Path('frontend/static/assets/avatars')
|
||||||
avatar_path.mkdir(parents=True, exist_ok=True)
|
avatar_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
class OrderMakerView(CreateAPIView):
|
class MakerView(CreateAPIView):
|
||||||
serializer_class = MakeOrderSerializer
|
serializer_class = MakeOrderSerializer
|
||||||
|
|
||||||
def post(self,request):
|
def post(self,request):
|
||||||
@ -103,6 +105,11 @@ class OrderView(viewsets.ViewSet):
|
|||||||
|
|
||||||
data = ListOrderSerializer(order).data
|
data = ListOrderSerializer(order).data
|
||||||
|
|
||||||
|
# if user is under a limit (penalty), inform him
|
||||||
|
is_penalized, time_out = Logics.is_penalized(request.user)
|
||||||
|
if is_penalized:
|
||||||
|
data['penalty'] = time_out
|
||||||
|
|
||||||
# Add booleans if user is maker, taker, partipant, buyer or seller
|
# Add booleans if user is maker, taker, partipant, buyer or seller
|
||||||
data['is_maker'] = order.maker == request.user
|
data['is_maker'] = order.maker == request.user
|
||||||
data['is_taker'] = order.taker == request.user
|
data['is_taker'] = order.taker == request.user
|
||||||
@ -121,45 +128,57 @@ class OrderView(viewsets.ViewSet):
|
|||||||
data['is_seller'] = Logics.is_seller(order,request.user)
|
data['is_seller'] = Logics.is_seller(order,request.user)
|
||||||
data['maker_nick'] = str(order.maker)
|
data['maker_nick'] = str(order.maker)
|
||||||
data['taker_nick'] = str(order.taker)
|
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
|
||||||
|
|
||||||
# 5) If status is 'waiting for maker bond' and user is MAKER, reply with a MAKER HODL invoice.
|
# If both bonds are locked, participants can see the trade in sats is also final.
|
||||||
if order.status == Order.Status.WFB and data['is_maker']:
|
if order.taker_bond:
|
||||||
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:
|
if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED:
|
||||||
|
# Seller sees the amount he pays
|
||||||
# 7.a) And if user is Seller, reply with an ESCROW HODL invoice.
|
|
||||||
if data['is_seller']:
|
if data['is_seller']:
|
||||||
valid, context = Logics.gen_escrow_hodl_invoice(order, request.user)
|
data['trade_satoshis'] = order.last_satoshis
|
||||||
if valid:
|
# Buyer sees the amount he receives
|
||||||
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']:
|
elif data['is_buyer']:
|
||||||
valid, context = Logics.buyer_invoice_amount(order, request.user)
|
data['trade_satoshis'] = Logics.buyer_invoice_amount(order, request.user)[1]['invoice_amount']
|
||||||
if valid:
|
|
||||||
data = {**data, **context}
|
# 5) If status is 'waiting for maker bond' and user is MAKER, reply with a MAKER hold invoice.
|
||||||
else:
|
if order.status == Order.Status.WFB and data['is_maker']:
|
||||||
return Response(context, status.HTTP_400_BAD_REQUEST)
|
valid, context = Logics.gen_maker_hold_invoice(order, request.user)
|
||||||
|
if valid:
|
||||||
|
data = {**data, **context}
|
||||||
|
else:
|
||||||
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# 6) If status is 'waiting for taker bond' and user is TAKER, reply with a TAKER hold invoice.
|
||||||
|
elif order.status == Order.Status.TAK and data['is_taker']:
|
||||||
|
valid, context = Logics.gen_taker_hold_invoice(order, request.user)
|
||||||
|
if valid:
|
||||||
|
data = {**data, **context}
|
||||||
|
else:
|
||||||
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# 7 a. ) If seller and status is 'WF2' or 'WFE'
|
||||||
|
elif data['is_seller'] and (order.status == Order.Status.WF2 or order.status == Order.Status.WFE):
|
||||||
|
|
||||||
|
# If the two bonds are locked, reply with an ESCROW hold invoice.
|
||||||
|
if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED:
|
||||||
|
valid, context = Logics.gen_escrow_hold_invoice(order, request.user)
|
||||||
|
if valid:
|
||||||
|
data = {**data, **context}
|
||||||
|
else:
|
||||||
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# 7.b) If user is Buyer and status is 'WF2' or 'WFI'
|
||||||
|
elif data['is_buyer'] and (order.status == Order.Status.WF2 or order.status == Order.Status.WFI):
|
||||||
|
|
||||||
|
# If the two bonds are locked, reply with an AMOUNT so he can send the buyer invoice.
|
||||||
|
if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED:
|
||||||
|
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
|
# 8) If status is 'CHA'or '' or '' and all HTLCS are in LOCKED
|
||||||
elif order.status == Order.Status.CHA: # TODO Add the other status
|
elif order.status == Order.Status.CHA: # TODO Add the other status
|
||||||
@ -167,6 +186,7 @@ class OrderView(viewsets.ViewSet):
|
|||||||
# add whether a collaborative cancel is pending
|
# add whether a collaborative cancel is pending
|
||||||
data['pending_cancel'] = order.is_pending_cancel
|
data['pending_cancel'] = order.is_pending_cancel
|
||||||
|
|
||||||
|
|
||||||
return Response(data, status.HTTP_200_OK)
|
return Response(data, status.HTTP_200_OK)
|
||||||
|
|
||||||
def take_update_confirm_dispute_cancel(self, request, format=None):
|
def take_update_confirm_dispute_cancel(self, request, format=None):
|
||||||
@ -192,35 +212,47 @@ class OrderView(viewsets.ViewSet):
|
|||||||
valid, context = Logics.validate_already_maker_or_taker(request.user)
|
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=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
Logics.take(order, request.user)
|
valid, context = Logics.take(order, request.user)
|
||||||
|
if not valid: return Response(context, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
else: Response({'bad_request':'This order is not public anymore.'}, status.HTTP_400_BAD_REQUEST)
|
else: Response({'bad_request':'This order is not public anymore.'}, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# 2) If action is update (invoice)
|
# Any other action is only allowed if the user is a participant
|
||||||
elif action == 'update_invoice' and invoice:
|
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)
|
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
|
# 3) If action is cancel
|
||||||
elif action == 'cancel':
|
elif action == 'cancel':
|
||||||
valid, context = Logics.cancel_order(order,request.user)
|
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
|
# 4) If action is confirm
|
||||||
elif action == '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
|
# 5) If action is dispute
|
||||||
elif action == '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:
|
elif action == 'rate' and rating:
|
||||||
valid, context = Logics.rate_counterparty(order,request.user, 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:
|
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)
|
return self.get(request)
|
||||||
|
|
||||||
@ -295,49 +327,76 @@ class UserView(APIView):
|
|||||||
# It is unlikely, but maybe the nickname is taken (1 in 20 Billion change)
|
# It is unlikely, but maybe the nickname is taken (1 in 20 Billion change)
|
||||||
context['found'] = 'Bad luck, this nickname is taken'
|
context['found'] = 'Bad luck, this nickname is taken'
|
||||||
context['bad_request'] = 'Enter a different token'
|
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):
|
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
|
# Only delete if user life is shorter than 30 minutes. Helps deleting users by mistake
|
||||||
# However it might be a long time recovered user
|
if user.date_joined < (timezone.now() - timedelta(minutes=30)):
|
||||||
# Only delete if user live is < 5 minutes
|
return Response(status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# TODO check if user exists AND it is not a maker or taker!
|
# Check if it is not a maker or taker!
|
||||||
if user is not None:
|
if not Logics.validate_already_maker_or_taker(user):
|
||||||
logout(request)
|
return Response({'bad_request':'User cannot be deleted while he is part of an order'}, status.HTTP_400_BAD_REQUEST)
|
||||||
user.delete()
|
|
||||||
|
|
||||||
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):
|
class BookView(ListAPIView):
|
||||||
serializer_class = ListOrderSerializer
|
serializer_class = ListOrderSerializer
|
||||||
|
queryset = Order.objects.filter(status=Order.Status.PUB)
|
||||||
|
|
||||||
def get(self,request, format=None):
|
def get(self,request, format=None):
|
||||||
currency = request.GET.get('currency')
|
currency = request.GET.get('currency')
|
||||||
type = request.GET.get('type')
|
type = request.GET.get('type')
|
||||||
queryset = Order.objects.filter(currency=currency, type=type, status=int(Order.Status.PUB))
|
|
||||||
|
queryset = Order.objects.filter(status=Order.Status.PUB)
|
||||||
|
|
||||||
|
# Currency 0 and type 2 are special cases treated as "ANY". (These are not really possible choices)
|
||||||
|
if int(currency) == 0 and int(type) != 2:
|
||||||
|
queryset = Order.objects.filter(type=type, status=Order.Status.PUB)
|
||||||
|
elif int(type) == 2 and int(currency) != 0:
|
||||||
|
queryset = Order.objects.filter(currency=currency, status=Order.Status.PUB)
|
||||||
|
elif not (int(currency) == 0 and int(type) == 2):
|
||||||
|
queryset = Order.objects.filter(currency=currency, type=type, status=Order.Status.PUB)
|
||||||
|
|
||||||
if len(queryset)== 0:
|
if len(queryset)== 0:
|
||||||
return Response({'not_found':'No orders found, be the first to make one'}, status=status.HTTP_404_NOT_FOUND)
|
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')
|
# queryset = queryset.order_by('created_at')
|
||||||
book_data = []
|
book_data = []
|
||||||
for order in queryset:
|
for order in queryset:
|
||||||
data = ListOrderSerializer(order).data
|
data = ListOrderSerializer(order).data
|
||||||
user = User.objects.filter(id=data['maker'])
|
data['maker_nick'] = str(order.maker)
|
||||||
if len(user) == 1:
|
|
||||||
data['maker_nick'] = user[0].username
|
|
||||||
|
|
||||||
# Non participants should not see the status or who is the taker
|
# Compute current premium for those orders that are explicitly priced.
|
||||||
for key in ('status','taker'):
|
data['price'], data['premium'] = Logics.price_and_premium_now(order)
|
||||||
|
|
||||||
|
for key in ('status','taker'): # Non participants should not see the status or who is the taker
|
||||||
del data[key]
|
del data[key]
|
||||||
|
|
||||||
book_data.append(data)
|
book_data.append(data)
|
||||||
|
|
||||||
return Response(book_data, status=status.HTTP_200_OK)
|
return Response(book_data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
class InfoView(ListAPIView):
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
context = {}
|
||||||
|
|
||||||
|
context['num_public_buy_orders'] = len(Order.objects.filter(type=Order.Types.BUY, status=Order.Status.PUB))
|
||||||
|
context['num_public_sell_orders'] = len(Order.objects.filter(type=Order.Types.BUY, status=Order.Status.PUB))
|
||||||
|
context['last_day_avg_btc_premium'] = None # Todo
|
||||||
|
context['num_active_robots'] = None
|
||||||
|
context['total_volume'] = None
|
||||||
|
|
||||||
|
return Response(context, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
4915
frontend/package-lock.json
generated
4915
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -26,8 +26,12 @@
|
|||||||
"@emotion/styled": "^11.6.0",
|
"@emotion/styled": "^11.6.0",
|
||||||
"@material-ui/core": "^4.12.3",
|
"@material-ui/core": "^4.12.3",
|
||||||
"@material-ui/icons": "^4.11.2",
|
"@material-ui/icons": "^4.11.2",
|
||||||
|
"@mui/material": "^5.2.7",
|
||||||
"@mui/system": "^5.2.6",
|
"@mui/system": "^5.2.6",
|
||||||
"material-ui-image": "^3.3.2",
|
"material-ui-image": "^3.3.2",
|
||||||
|
"react-native": "^0.66.4",
|
||||||
|
"react-native-svg": "^12.1.1",
|
||||||
|
"react-qr-code": "^2.0.3",
|
||||||
"react-router-dom": "^5.2.0"
|
"react-router-dom": "^5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Button , Divider, Card, CardActionArea, CardContent, Typography, Grid, Select, MenuItem, FormControl, FormHelperText, List, ListItem, ListItemText, Avatar, RouterLink, ListItemAvatar} from "@material-ui/core"
|
import { Paper, Button , Divider, CircularProgress, ListItemButton, Typography, Grid, Select, MenuItem, FormControl, FormHelperText, List, ListItem, ListItemText, Avatar, RouterLink, ListItemAvatar} from "@mui/material";
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export default class BookPage extends Component {
|
export default class BookPage extends Component {
|
||||||
@ -7,21 +7,24 @@ export default class BookPage extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
orders: new Array(),
|
orders: new Array(),
|
||||||
currency: 1,
|
currency: 0,
|
||||||
type: 1,
|
type: 1,
|
||||||
|
currencies_dict: {"0":"ANY"},
|
||||||
|
loading: true,
|
||||||
};
|
};
|
||||||
this.getOrderDetails()
|
this.getCurrencyDict()
|
||||||
|
this.getOrderDetails(this.state.type,this.state.currency)
|
||||||
this.state.currencyCode = this.getCurrencyCode(this.state.currency)
|
this.state.currencyCode = this.getCurrencyCode(this.state.currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show message to be the first one to make an order
|
getOrderDetails(type,currency) {
|
||||||
getOrderDetails() {
|
fetch('/api/book' + '?currency=' + currency + "&type=" + type)
|
||||||
fetch('/api/book' + '?currency=' + this.state.currency + "&type=" + this.state.type)
|
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => //console.log(data));
|
.then((data) =>
|
||||||
this.setState({
|
this.setState({
|
||||||
orders: data,
|
orders: data,
|
||||||
not_found: data.not_found,
|
not_found: data.not_found,
|
||||||
|
loading: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,25 +33,33 @@ export default class BookPage extends Component {
|
|||||||
this.props.history.push('/order/' + e);
|
this.props.history.push('/order/' + e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make these two functions sequential. getOrderDetails needs setState to be finish beforehand.
|
|
||||||
handleTypeChange=(e)=>{
|
handleTypeChange=(e)=>{
|
||||||
this.setState({
|
this.setState({
|
||||||
type: e.target.value,
|
type: e.target.value,
|
||||||
|
loading: true,
|
||||||
});
|
});
|
||||||
this.getOrderDetails();
|
this.getOrderDetails(e.target.value,this.state.currency);
|
||||||
}
|
}
|
||||||
handleCurrencyChange=(e)=>{
|
handleCurrencyChange=(e)=>{
|
||||||
this.setState({
|
this.setState({
|
||||||
currency: e.target.value,
|
currency: e.target.value,
|
||||||
currencyCode: this.getCurrencyCode(e.target.value),
|
currencyCode: this.getCurrencyCode(e.target.value),
|
||||||
|
loading: true,
|
||||||
})
|
})
|
||||||
this.getOrderDetails();
|
this.getOrderDetails(this.state.type, e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrencyDict() {
|
||||||
|
fetch('/static/assets/currencies.json')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) =>
|
||||||
|
this.setState({
|
||||||
|
currencies_dict: data
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets currency code (3 letters) from numeric (e.g., 1 -> USD)
|
|
||||||
// Improve this function so currencies are read from json
|
|
||||||
getCurrencyCode(val){
|
getCurrencyCode(val){
|
||||||
return (val == 1 ) ? "USD": ((val == 2 ) ? "EUR":"ETH")
|
return this.state.currencies_dict[val.toString()]
|
||||||
}
|
}
|
||||||
|
|
||||||
// pretty numbers
|
// pretty numbers
|
||||||
@ -56,60 +67,53 @@ export default class BookPage extends Component {
|
|||||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
}
|
}
|
||||||
|
|
||||||
bookCards=()=>{
|
bookListItems=()=>{
|
||||||
return (this.state.orders.map((order) =>
|
return (this.state.orders.map((order) =>
|
||||||
<Grid container item sm={4}>
|
<>
|
||||||
<Card elevation={6} sx={{ width: 945 }}>
|
<ListItemButton value={order.id} onClick={() => this.handleCardClick(order.id)}>
|
||||||
|
|
||||||
<CardActionArea value={order.id} onClick={() => this.handleCardClick(order.id)}>
|
<ListItemAvatar >
|
||||||
<CardContent>
|
<Avatar
|
||||||
|
alt={order.maker_nick}
|
||||||
|
src={window.location.origin +'/static/assets/avatars/' + order.maker_nick + '.png'}
|
||||||
|
/>
|
||||||
|
</ListItemAvatar>
|
||||||
|
|
||||||
|
<ListItemText>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{order.maker_nick+" "}
|
||||||
|
</Typography>
|
||||||
|
</ListItemText>
|
||||||
|
|
||||||
|
<ListItemText align='left'>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
<b>{order.type ? " Sells ": " Buys "} BTC </b> for {parseFloat(
|
||||||
|
parseFloat(order.amount).toFixed(4))+" "+ this.getCurrencyCode(order.currency)+" "}
|
||||||
|
</Typography>
|
||||||
|
</ListItemText>
|
||||||
|
|
||||||
<List dense="true">
|
<ListItemText align='left'>
|
||||||
<ListItem >
|
<Typography variant="subtitle1">
|
||||||
<ListItemAvatar >
|
via <b>{order.payment_method}</b>
|
||||||
<Avatar
|
</Typography>
|
||||||
alt={order.maker_nick}
|
</ListItemText>
|
||||||
src={window.location.origin +'/static/assets/avatars/' + order.maker_nick + '.png'}
|
|
||||||
/>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText>
|
|
||||||
<Typography gutterBottom variant="h6">
|
|
||||||
{order.maker_nick}
|
|
||||||
</Typography>
|
|
||||||
</ListItemText>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
{/* CARD PARAGRAPH CONTENT */}
|
<ListItemText align='right'>
|
||||||
<ListItemText>
|
<Typography variant="subtitle1">
|
||||||
<Typography variant="subtitle1" color="text.secondary">
|
at <b>{this.pn(order.price) + " " + this.getCurrencyCode(order.currency)}/BTC</b>
|
||||||
◑{order.type == 0 ? <b> Buys </b>: <b> Sells </b>}
|
</Typography>
|
||||||
<b>{parseFloat(parseFloat(order.amount).toFixed(4))}
|
</ListItemText>
|
||||||
{" " +this.getCurrencyCode(order.currency)}</b> <a> worth of bitcoin</a>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="subtitle1" color="text.secondary">
|
<ListItemText align='right'>
|
||||||
◑ Payment via <b>{order.payment_method}</b>
|
<Typography variant="subtitle1">
|
||||||
</Typography>
|
{order.premium > 1 ? "🔴" : "🔵" } <b>{parseFloat(parseFloat(order.premium).toFixed(4))}%</b>
|
||||||
{/*
|
</Typography>
|
||||||
<Typography variant="subtitle1" color="text.secondary">
|
</ListItemText>
|
||||||
◑ Priced {order.is_explicit ?
|
|
||||||
" explicitly at " + this.pn(order.satoshis) + " Sats" : (
|
|
||||||
" at " +
|
|
||||||
parseFloat(parseFloat(order.premium).toFixed(4)) + "% over the market"
|
|
||||||
)}
|
|
||||||
</Typography> */}
|
|
||||||
|
|
||||||
<Typography variant="subtitle1" color="text.secondary">
|
</ListItemButton>
|
||||||
◑ <b>{" 42,354 "}{this.getCurrencyCode(order.currency)}/BTC</b> (Binance API)
|
|
||||||
</Typography>
|
<Divider/>
|
||||||
</ListItemText>
|
</>
|
||||||
|
|
||||||
</List>
|
|
||||||
|
|
||||||
</CardContent>
|
|
||||||
</CardActionArea>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +121,7 @@ export default class BookPage extends Component {
|
|||||||
return (
|
return (
|
||||||
<Grid className='orderBook' container spacing={1}>
|
<Grid className='orderBook' container spacing={1}>
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Typography component="h4" variant="h4">
|
<Typography component="h2" variant="h2">
|
||||||
Order Book
|
Order Book
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -135,7 +139,7 @@ export default class BookPage extends Component {
|
|||||||
style: {textAlign:"center"}
|
style: {textAlign:"center"}
|
||||||
}}
|
}}
|
||||||
onChange={this.handleTypeChange}
|
onChange={this.handleTypeChange}
|
||||||
>
|
> <MenuItem value={2}>ANY</MenuItem>
|
||||||
<MenuItem value={1}>BUY</MenuItem>
|
<MenuItem value={1}>BUY</MenuItem>
|
||||||
<MenuItem value={0}>SELL</MenuItem>
|
<MenuItem value={0}>SELL</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
@ -155,20 +159,26 @@ export default class BookPage extends Component {
|
|||||||
style: {textAlign:"center"}
|
style: {textAlign:"center"}
|
||||||
}}
|
}}
|
||||||
onChange={this.handleCurrencyChange}
|
onChange={this.handleCurrencyChange}
|
||||||
>
|
> <MenuItem value={0}>ANY</MenuItem>
|
||||||
<MenuItem value={1}>USD</MenuItem>
|
{
|
||||||
<MenuItem value={2}>EUR</MenuItem>
|
Object.entries(this.state.currencies_dict)
|
||||||
<MenuItem value={3}>ETH</MenuItem>
|
.map( ([key, value]) => <MenuItem value={parseInt(key)}>{value}</MenuItem> )
|
||||||
|
}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
{ this.state.not_found ? "" :
|
{ this.state.not_found ? "" :
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Typography component="h5" variant="h5">
|
<Typography component="h5" variant="h5">
|
||||||
You are {this.state.type == 0 ? " selling " : " buying "} BTC for {this.state.currencyCode}
|
You are {this.state.type == 0 ? <b> selling </b> : (this.state.type == 1 ? <b> buying </b> :" looking at all ")} BTC for {this.state.currencyCode}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
|
{/* If loading, show circular progressbar */}
|
||||||
|
{this.state.loading ?
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<CircularProgress />
|
||||||
|
</Grid> : ""}
|
||||||
|
|
||||||
{ this.state.not_found ?
|
{ this.state.not_found ?
|
||||||
(<Grid item xs={12} align="center">
|
(<Grid item xs={12} align="center">
|
||||||
@ -184,7 +194,14 @@ export default class BookPage extends Component {
|
|||||||
Be the first one to create an order
|
Be the first one to create an order
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>)
|
</Grid>)
|
||||||
: this.bookCards()
|
:
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Paper elevation={0} style={{width: 1100, maxHeight: 600, overflow: 'auto'}}>
|
||||||
|
<List >
|
||||||
|
{this.bookListItems()}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
}
|
}
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Button color="secondary" variant="contained" to="/" component={Link}>
|
<Button color="secondary" variant="contained" to="/" component={Link}>
|
||||||
|
@ -2,11 +2,9 @@ import React, { Component } from "react";
|
|||||||
import { BrowserRouter as Router, Switch, Route, Link, Redirect } from "react-router-dom";
|
import { BrowserRouter as Router, Switch, Route, Link, Redirect } from "react-router-dom";
|
||||||
|
|
||||||
import UserGenPage from "./UserGenPage";
|
import UserGenPage from "./UserGenPage";
|
||||||
import LoginPage from "./LoginPage.js";
|
|
||||||
import MakerPage from "./MakerPage";
|
import MakerPage from "./MakerPage";
|
||||||
import BookPage from "./BookPage";
|
import BookPage from "./BookPage";
|
||||||
import OrderPage from "./OrderPage";
|
import OrderPage from "./OrderPage";
|
||||||
import WaitingRoomPage from "./WaitingRoomPage";
|
|
||||||
|
|
||||||
export default class HomePage extends Component {
|
export default class HomePage extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -19,11 +17,9 @@ export default class HomePage extends Component {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/' component={UserGenPage}/>
|
<Route exact path='/' component={UserGenPage}/>
|
||||||
<Route path='/home'><p>You are at the start page</p></Route>
|
<Route path='/home'><p>You are at the start page</p></Route>
|
||||||
<Route path='/login'component={LoginPage}/>
|
|
||||||
<Route path='/make' component={MakerPage}/>
|
<Route path='/make' component={MakerPage}/>
|
||||||
<Route path='/book' component={BookPage}/>
|
<Route path='/book' component={BookPage}/>
|
||||||
<Route path="/order/:orderId" component={OrderPage}/>
|
<Route path="/order/:orderId" component={OrderPage}/>
|
||||||
<Route path='/wait' component={WaitingRoomPage}/>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
|
|
||||||
export default class LoginPage extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <p>This is the login page</p>;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Paper, Alert, AlertTitle, 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 "@mui/material"
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
@ -19,12 +19,18 @@ function getCookie(name) {
|
|||||||
}
|
}
|
||||||
const csrftoken = getCookie('csrftoken');
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
|
||||||
|
// pretty numbers
|
||||||
|
function pn(x) {
|
||||||
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
}
|
||||||
|
|
||||||
export default class MakerPage extends Component {
|
export default class MakerPage extends Component {
|
||||||
defaultCurrency = 1;
|
defaultCurrency = 1;
|
||||||
defaultCurrencyCode = 'USD';
|
defaultCurrencyCode = 'USD';
|
||||||
defaultAmount = 0 ;
|
defaultPaymentMethod = "not specified";
|
||||||
defaultPaymentMethod = "Not specified";
|
|
||||||
defaultPremium = 0;
|
defaultPremium = 0;
|
||||||
|
minTradeSats = 10000;
|
||||||
|
maxTradeSats = 500000;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@ -33,11 +39,12 @@ export default class MakerPage extends Component {
|
|||||||
type: 0,
|
type: 0,
|
||||||
currency: this.defaultCurrency,
|
currency: this.defaultCurrency,
|
||||||
currencyCode: this.defaultCurrencyCode,
|
currencyCode: this.defaultCurrencyCode,
|
||||||
amount: this.defaultAmount,
|
|
||||||
payment_method: this.defaultPaymentMethod,
|
payment_method: this.defaultPaymentMethod,
|
||||||
premium: 0,
|
premium: 0,
|
||||||
satoshis: null,
|
satoshis: null,
|
||||||
|
currencies_dict: {"1":"USD"}
|
||||||
}
|
}
|
||||||
|
this.getCurrencyDict()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTypeChange=(e)=>{
|
handleTypeChange=(e)=>{
|
||||||
@ -46,10 +53,9 @@ export default class MakerPage extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleCurrencyChange=(e)=>{
|
handleCurrencyChange=(e)=>{
|
||||||
var code = (e.target.value == 1 ) ? "USD": ((e.target.value == 2 ) ? "EUR":"ETH")
|
|
||||||
this.setState({
|
this.setState({
|
||||||
currency: e.target.value,
|
currency: e.target.value,
|
||||||
currencyCode: code,
|
currencyCode: this.getCurrencyCode(e.target.value),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleAmountChange=(e)=>{
|
handleAmountChange=(e)=>{
|
||||||
@ -68,9 +74,16 @@ export default class MakerPage extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleSatoshisChange=(e)=>{
|
handleSatoshisChange=(e)=>{
|
||||||
|
var bad_sats = e.target.value > this.maxTradeSats ?
|
||||||
|
("Must be less than "+pn(this.maxTradeSats)):
|
||||||
|
(e.target.value < this.minTradeSats ?
|
||||||
|
("Must be more than "+pn(this.minTradeSats)): null)
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
satoshis: e.target.value,
|
satoshis: e.target.value,
|
||||||
});
|
badSatoshis: bad_sats,
|
||||||
|
})
|
||||||
|
;
|
||||||
}
|
}
|
||||||
handleClickRelative=(e)=>{
|
handleClickRelative=(e)=>{
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -82,12 +95,13 @@ export default class MakerPage extends Component {
|
|||||||
handleClickExplicit=(e)=>{
|
handleClickExplicit=(e)=>{
|
||||||
this.setState({
|
this.setState({
|
||||||
isExplicit: true,
|
isExplicit: true,
|
||||||
satoshis: 10000,
|
|
||||||
premium: null,
|
premium: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCreateOfferButtonPressed=()=>{
|
handleCreateOfferButtonPressed=()=>{
|
||||||
|
this.state.amount == null ? this.setState({amount: 0}) : null;
|
||||||
|
|
||||||
console.log(this.state)
|
console.log(this.state)
|
||||||
const requestOptions = {
|
const requestOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -108,23 +122,35 @@ export default class MakerPage extends Component {
|
|||||||
& (data.id ? this.props.history.push('/order/' + data.id) :"")));
|
& (data.id ? this.props.history.push('/order/' + data.id) :"")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCurrencyDict() {
|
||||||
|
fetch('/static/assets/currencies.json')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) =>
|
||||||
|
this.setState({
|
||||||
|
currencies_dict: data
|
||||||
|
}));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrencyCode(val){
|
||||||
|
return this.state.currencies_dict[val.toString()]
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={1}>
|
<Grid container xs={12} align="center" spacing={1}>
|
||||||
<Grid item xs={12} align="center">
|
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Typography component="h4" variant="h4">
|
<Typography component="h2" variant="h2">
|
||||||
Make an Order
|
Order Maker
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Paper elevation={12} style={{ padding: 8,}}>
|
<Grid item xs={12} align="center" spacing={1}>
|
||||||
<Grid item xs={12} align="center">
|
<Paper elevation={12} style={{ padding: 8, width:350, align:'center'}}>
|
||||||
|
<Grid item xs={12} align="center" spacing={1}>
|
||||||
<FormControl component="fieldset">
|
<FormControl component="fieldset">
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
<div align='center'>
|
Buy or Sell Bitcoin?
|
||||||
Choose Buy or Sell Bitcoin
|
</FormHelperText>
|
||||||
</div>
|
|
||||||
</FormHelperText>
|
|
||||||
<RadioGroup row defaultValue="0" onChange={this.handleTypeChange}>
|
<RadioGroup row defaultValue="0" onChange={this.handleTypeChange}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
value="0"
|
value="0"
|
||||||
@ -141,35 +167,36 @@ export default class MakerPage extends Component {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} align="center">
|
<Grid container xs={11} align="center">
|
||||||
<FormControl >
|
<TextField
|
||||||
<TextField
|
error={this.state.amount == 0}
|
||||||
label="Amount of Fiat to Trade"
|
helperText={this.state.amount == 0 ? 'Must be more than 0' : null}
|
||||||
type="number"
|
label="Amount"
|
||||||
required="true"
|
type="number"
|
||||||
defaultValue={this.defaultAmount}
|
required="true"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
min:0 ,
|
min:0 ,
|
||||||
style: {textAlign:"center"}
|
style: {textAlign:"center"}
|
||||||
}}
|
}}
|
||||||
onChange={this.handleAmountChange}
|
onChange={this.handleAmountChange}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Select Payment Currency"
|
label="Select Payment Currency"
|
||||||
required="true"
|
required="true"
|
||||||
defaultValue={this.defaultCurrency}
|
defaultValue={this.defaultCurrency}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
style: {textAlign:"center"}
|
style: {textAlign:"center"}
|
||||||
}}
|
}}
|
||||||
onChange={this.handleCurrencyChange}
|
onChange={this.handleCurrencyChange}
|
||||||
>
|
>
|
||||||
<MenuItem value={1}>USD</MenuItem>
|
{
|
||||||
<MenuItem value={2}>EUR</MenuItem>
|
Object.entries(this.state.currencies_dict)
|
||||||
<MenuItem value={3}>ETH</MenuItem>
|
.map( ([key, value]) => <MenuItem value={parseInt(key)}>{value}</MenuItem> )
|
||||||
</Select>
|
}
|
||||||
</FormControl>
|
</Select>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
<br/>
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<FormControl >
|
<FormControl >
|
||||||
<TextField
|
<TextField
|
||||||
@ -177,14 +204,21 @@ export default class MakerPage extends Component {
|
|||||||
type="text"
|
type="text"
|
||||||
require={true}
|
require={true}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
style: {textAlign:"center"}
|
style: {textAlign:"center"},
|
||||||
|
maxLength: 35
|
||||||
}}
|
}}
|
||||||
onChange={this.handlePaymentMethodChange}
|
onChange={this.handlePaymentMethodChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<FormControl component="fieldset">
|
<FormControl component="fieldset">
|
||||||
|
<FormHelperText >
|
||||||
|
<div align='center'>
|
||||||
|
Choose a Pricing Method
|
||||||
|
</div>
|
||||||
|
</FormHelperText>
|
||||||
<RadioGroup row defaultValue="relative">
|
<RadioGroup row defaultValue="relative">
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
value="relative"
|
value="relative"
|
||||||
@ -201,24 +235,21 @@ export default class MakerPage extends Component {
|
|||||||
onClick={this.handleClickExplicit}
|
onClick={this.handleClickExplicit}
|
||||||
/>
|
/>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<FormHelperText >
|
|
||||||
<div align='center'>
|
|
||||||
Choose a Pricing Method
|
|
||||||
</div>
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
{/* conditional shows either Premium % field or Satoshis field based on pricing method */}
|
{/* conditional shows either Premium % field or Satoshis field based on pricing method */}
|
||||||
{ this.state.isExplicit
|
{ this.state.isExplicit
|
||||||
? <Grid item xs={12} align="center">
|
? <Grid item xs={12} align="center">
|
||||||
<TextField
|
<TextField
|
||||||
label="Explicit Amount in Satoshis"
|
label="Satoshis"
|
||||||
|
error={this.state.badSatoshis}
|
||||||
|
helperText={this.state.badSatoshis}
|
||||||
type="number"
|
type="number"
|
||||||
required="true"
|
required="true"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
// TODO read these from .env file
|
// TODO read these from .env file
|
||||||
min:10000 ,
|
min:this.minTradeSats ,
|
||||||
max:500000 ,
|
max:this.maxTradeSats ,
|
||||||
style: {textAlign:"center"}
|
style: {textAlign:"center"}
|
||||||
}}
|
}}
|
||||||
onChange={this.handleSatoshisChange}
|
onChange={this.handleSatoshisChange}
|
||||||
@ -238,7 +269,7 @@ export default class MakerPage extends Component {
|
|||||||
</Grid>
|
</Grid>
|
||||||
}
|
}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Button color="primary" variant="contained" onClick={this.handleCreateOfferButtonPressed} >
|
<Button color="primary" variant="contained" onClick={this.handleCreateOfferButtonPressed} >
|
||||||
Create Order
|
Create Order
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Paper, Button , Grid, Typography, List, ListItem, ListItemText, ListItemAvatar, Avatar, Divider} from "@material-ui/core"
|
import { Alert, Paper, CircularProgress, Button , Grid, Typography, List, ListItem, ListItemText, ListItemAvatar, Avatar, Divider, Box, LinearProgress} from "@mui/material"
|
||||||
import { Link } from 'react-router-dom'
|
import TradeBox from "./TradeBox";
|
||||||
|
|
||||||
function msToTime(duration) {
|
function msToTime(duration) {
|
||||||
var seconds = Math.floor((duration / 1000) % 60),
|
var seconds = Math.floor((duration / 1000) % 60),
|
||||||
@ -13,6 +13,33 @@ function msToTime(duration) {
|
|||||||
return hours + "h " + minutes + "m " + seconds + "s";
|
return hours + "h " + minutes + "m " + seconds + "s";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TO DO fix Progress bar to go from 100 to 0, from total_expiration time, showing time_left
|
||||||
|
function LinearDeterminate() {
|
||||||
|
const [progress, setProgress] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setProgress((oldProgress) => {
|
||||||
|
if (oldProgress === 0) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
const diff = 1;
|
||||||
|
return Math.max(oldProgress - diff, 0);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<LinearProgress variant="determinate" value={progress} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
let cookieValue = null;
|
let cookieValue = null;
|
||||||
if (document.cookie && document.cookie !== '') {
|
if (document.cookie && document.cookie !== '') {
|
||||||
@ -40,16 +67,21 @@ export default class OrderPage extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
isExplicit: false,
|
isExplicit: false,
|
||||||
|
delay: 10000, // Refresh every 10 seconds
|
||||||
|
currencies_dict: {"1":"USD"}
|
||||||
};
|
};
|
||||||
this.orderId = this.props.match.params.orderId;
|
this.orderId = this.props.match.params.orderId;
|
||||||
|
this.getCurrencyDict();
|
||||||
this.getOrderDetails();
|
this.getOrderDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrderDetails() {
|
getOrderDetails() {
|
||||||
|
this.setState(null)
|
||||||
fetch('/api/order' + '?order_id=' + this.orderId)
|
fetch('/api/order' + '?order_id=' + this.orderId)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {console.log(data) &
|
||||||
this.setState({
|
this.setState({
|
||||||
|
id: data.id,
|
||||||
statusCode: data.status,
|
statusCode: data.status,
|
||||||
statusText: data.status_message,
|
statusText: data.status_message,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
@ -65,18 +97,40 @@ export default class OrderPage extends Component {
|
|||||||
makerNick: data.maker_nick,
|
makerNick: data.maker_nick,
|
||||||
takerId: data.taker,
|
takerId: data.taker,
|
||||||
takerNick: data.taker_nick,
|
takerNick: data.taker_nick,
|
||||||
isBuyer:data.buyer,
|
isMaker: data.is_maker,
|
||||||
isSeller:data.seller,
|
isTaker: data.is_taker,
|
||||||
expiresAt:data.expires_at,
|
isBuyer: data.is_buyer,
|
||||||
badRequest:data.bad_request,
|
isSeller: data.is_seller,
|
||||||
|
penalty: data.penalty,
|
||||||
|
expiresAt: data.expires_at,
|
||||||
|
badRequest: data.bad_request,
|
||||||
|
bondInvoice: data.bond_invoice,
|
||||||
|
bondSatoshis: data.bond_satoshis,
|
||||||
|
escrowInvoice: data.escrow_invoice,
|
||||||
|
escrowSatoshis: data.escrow_satoshis,
|
||||||
|
invoiceAmount: data.invoice_amount,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets currency code (3 letters) from numeric (e.g., 1 -> USD)
|
// These are used to refresh the data
|
||||||
// Improve this function so currencies are read from json
|
componentDidMount() {
|
||||||
getCurrencyCode(val){
|
this.interval = setInterval(this.tick, this.state.delay);
|
||||||
return (val == 1 ) ? "USD": ((val == 2 ) ? "EUR":"ETH")
|
}
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (prevState.delay !== this.state.delay) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
this.interval = setInterval(this.tick, this.state.delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
tick = () => {
|
||||||
|
this.getOrderDetails();
|
||||||
|
}
|
||||||
|
handleDelayChange = (e) => {
|
||||||
|
this.setState({ delay: Number(e.target.value) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix to use proper react props
|
// Fix to use proper react props
|
||||||
@ -97,13 +151,40 @@ export default class OrderPage extends Component {
|
|||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => (console.log(data) & this.getOrderDetails(data.id)));
|
.then((data) => (console.log(data) & this.getOrderDetails(data.id)));
|
||||||
}
|
}
|
||||||
|
getCurrencyDict() {
|
||||||
|
fetch('/static/assets/currencies.json')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) =>
|
||||||
|
this.setState({
|
||||||
|
currencies_dict: data
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrencyCode(val){
|
||||||
|
let code = val ? this.state.currencies_dict[val.toString()] : ""
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
render (){
|
handleClickCancelOrderButton=()=>{
|
||||||
return (
|
console.log(this.state)
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
|
||||||
|
body: JSON.stringify({
|
||||||
|
'action':'cancel',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
fetch('/api/order/' + '?order_id=' + this.orderId, requestOptions)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => (console.log(data) & this.getOrderDetails(data.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
orderBox=()=>{
|
||||||
|
return(
|
||||||
<Grid container spacing={1}>
|
<Grid container spacing={1}>
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Typography component="h5" variant="h5">
|
<Typography component="h5" variant="h5">
|
||||||
BTC {this.state.type ? " Sell " : " Buy "} Order
|
{this.state.type ? "Sell " : "Buy "} Order Details
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paper elevation={12} style={{ padding: 8,}}>
|
<Paper elevation={12} style={{ padding: 8,}}>
|
||||||
<List dense="true">
|
<List dense="true">
|
||||||
@ -114,7 +195,7 @@ export default class OrderPage extends Component {
|
|||||||
src={window.location.origin +'/static/assets/avatars/' + this.state.makerNick + '.png'}
|
src={window.location.origin +'/static/assets/avatars/' + this.state.makerNick + '.png'}
|
||||||
/>
|
/>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={this.state.makerNick} secondary="Order maker" align="right"/>
|
<ListItemText primary={this.state.makerNick + (this.state.type ? " (Seller)" : " (Buyer)")} secondary="Order maker" align="right"/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
@ -123,7 +204,7 @@ export default class OrderPage extends Component {
|
|||||||
{this.state.takerNick!='None' ?
|
{this.state.takerNick!='None' ?
|
||||||
<>
|
<>
|
||||||
<ListItem align="left">
|
<ListItem align="left">
|
||||||
<ListItemText primary={this.state.takerNick} secondary="Order taker"/>
|
<ListItemText primary={this.state.takerNick + (this.state.type ? " (Buyer)" : " (Seller)")} secondary="Order taker"/>
|
||||||
<ListItemAvatar >
|
<ListItemAvatar >
|
||||||
<Avatar
|
<Avatar
|
||||||
alt={this.state.makerNick}
|
alt={this.state.makerNick}
|
||||||
@ -144,7 +225,7 @@ export default class OrderPage extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemText primary={parseFloat(parseFloat(this.state.amount).toFixed(4))+" "+this.state.currencyCode} secondary="Amount and currency requested"/>
|
<ListItemText primary={parseFloat(parseFloat(this.state.amount).toFixed(4))+" "+this.state.currencyCode} secondary="Amount"/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ListItem>
|
<ListItem>
|
||||||
@ -164,23 +245,87 @@ export default class OrderPage extends Component {
|
|||||||
<ListItemText primary={'#'+this.orderId} secondary="Order ID"/>
|
<ListItemText primary={'#'+this.orderId} secondary="Order ID"/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemText primary={msToTime( new Date(this.state.expiresAt) - Date.now())} secondary="Expires in "/>
|
<ListItemText primary={msToTime( new Date(this.state.expiresAt) - Date.now())} secondary="Expires"/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<LinearDeterminate />
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
|
{/* If the user has a penalty/limit */}
|
||||||
|
{this.state.penalty ?
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Alert severity="warning" sx={{maxWidth:360}}>
|
||||||
|
You cannot take an order yet! Wait {this.state.penalty} seconds
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
: null}
|
||||||
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} align="center">
|
{/* Participants cannot see the Back or Take Order buttons */}
|
||||||
{this.state.isParticipant ? "" : <Button variant='contained' color='primary' onClick={this.handleClickTakeOrderButton}>Take Order</Button>}
|
{this.state.isParticipant ? "" :
|
||||||
</Grid>
|
<>
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Button variant='contained' color='secondary' onClick={this.handleClickBackButton}>Back</Button>
|
<Button variant='contained' color='primary' onClick={this.handleClickTakeOrderButton}>Take Order</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button variant='contained' color='secondary' onClick={this.handleClickBackButton}>Back</Button>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Makers can cancel before commiting the bond (status 0)*/}
|
||||||
|
{this.state.isMaker & this.state.statusCode == 0 ?
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button variant='contained' color='secondary' onClick={this.handleClickCancelOrderButton}>Cancel</Button>
|
||||||
|
</Grid>
|
||||||
|
:""}
|
||||||
|
|
||||||
|
{/* Takers can cancel before commiting the bond (status 3)*/}
|
||||||
|
{this.state.isTaker & this.state.statusCode == 3 ?
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button variant='contained' color='secondary' onClick={this.handleClickCancelOrderButton}>Cancel</Button>
|
||||||
|
</Grid>
|
||||||
|
:""}
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderDetailsPage (){
|
||||||
|
return(
|
||||||
|
this.state.badRequest ?
|
||||||
|
<div align='center'>
|
||||||
|
<Typography component="subtitle2" variant="subtitle2" color="secondary" >
|
||||||
|
{this.state.badRequest}<br/>
|
||||||
|
</Typography>
|
||||||
|
<Button variant='contained' color='secondary' onClick={this.handleClickBackButton}>Back</Button>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
(this.state.isParticipant ?
|
||||||
|
<Grid container xs={12} align="center" spacing={2}>
|
||||||
|
<Grid item xs={6} align="left">
|
||||||
|
{this.orderBox()}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} align="left">
|
||||||
|
<TradeBox data={this.state}/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
:
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
{this.orderBox()}
|
||||||
|
</Grid>)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render (){
|
||||||
|
return (
|
||||||
|
// Only so nothing shows while requesting the first batch of data
|
||||||
|
(this.state.statusCode == null & this.state.badRequest == null) ? <CircularProgress /> : this.orderDetailsPage()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
400
frontend/src/components/TradeBox.js
Normal file
400
frontend/src/components/TradeBox.js
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Paper, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider} from "@mui/material"
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
|
||||||
|
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, ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TradeBox extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
showQRInvoice=()=>{
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="body2" variant="body2">
|
||||||
|
Robots around here usually show commitment
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
{this.props.data.isMaker ?
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>Lock {pn(this.props.data.bondSatoshis)} Sats to PUBLISH order </b>
|
||||||
|
</Typography>
|
||||||
|
:
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>Lock {pn(this.props.data.bondSatoshis)} Sats to TAKE the order </b>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<QRCode value={this.props.data.bondInvoice} size={305}/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<TextField
|
||||||
|
hiddenLabel
|
||||||
|
variant="filled"
|
||||||
|
size="small"
|
||||||
|
defaultValue={this.props.data.bondInvoice}
|
||||||
|
disabled="true"
|
||||||
|
helperText="This is a hold invoice. It will not be charged if the order succeeds or expires.
|
||||||
|
It will be charged if the order is cancelled or you lose a dispute."
|
||||||
|
color = "secondary"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showEscrowQRInvoice=()=>{
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>Deposit {pn(this.props.data.escrowSatoshis)} Sats as trade collateral </b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<QRCode value={this.props.data.escrowInvoice} size={305}/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<TextField
|
||||||
|
hiddenLabel
|
||||||
|
variant="filled"
|
||||||
|
size="small"
|
||||||
|
defaultValue={this.props.data.escrowInvoice}
|
||||||
|
disabled="true"
|
||||||
|
helperText="This is a hold LN invoice. It will be charged once the buyer confirms he sent the fiat."
|
||||||
|
color = "secondary"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showTakerFound=()=>{
|
||||||
|
|
||||||
|
// TODO Make some sound here! The maker might have been waiting for long
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>A taker has been found! </b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Divider/>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="body2" variant="body2">
|
||||||
|
Please wait for the taker to confirm his commitment by locking a bond.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showMakerWait=()=>{
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b> Your order is public, wait for a taker. </b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
|
||||||
|
<List dense="true">
|
||||||
|
<Divider/>
|
||||||
|
<ListItem>
|
||||||
|
<Typography component="body2" variant="body2" align="left">
|
||||||
|
<p>Be patient while robots check the book.
|
||||||
|
It might take some time. This box will ring 🔊 once a robot takes your order. </p>
|
||||||
|
<p>Please note that if your premium is too high, or if your currency or payment
|
||||||
|
methods are not popular, your order might expire untaken. Your bond will
|
||||||
|
return to you (no action needed).</p>
|
||||||
|
</Typography>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{/* TODO API sends data for a more confortable wait */}
|
||||||
|
<Divider/>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary={999} secondary="Robots looking at the book"/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<Divider/>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary={999} secondary={"Active orders for " + this.props.data.currencyCode}/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<Divider/>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="33%" secondary="Premium percentile" />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputInvoiceChanged=(e)=>{
|
||||||
|
this.setState({
|
||||||
|
invoice: e.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix this. It's clunky because it takes time. this.props.data does not refresh until next refresh of OrderPage.
|
||||||
|
|
||||||
|
handleClickSubmitInvoiceButton=()=>{
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
|
||||||
|
body: JSON.stringify({
|
||||||
|
'action':'update_invoice',
|
||||||
|
'invoice': this.state.invoice,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => (this.props.data = data));
|
||||||
|
}
|
||||||
|
|
||||||
|
showInputInvoice(){
|
||||||
|
return (
|
||||||
|
|
||||||
|
// TODO Camera option to read QR
|
||||||
|
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b> Submit a LN invoice for {pn(this.props.data.invoiceAmount)} Sats </b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="left">
|
||||||
|
<Typography component="body2" variant="body2">
|
||||||
|
The taker is committed! Before letting you send {" "+ parseFloat(parseFloat(this.props.data.amount).toFixed(4))+
|
||||||
|
" "+ this.props.data.currencyCode}, we want to make sure you are able to receive the BTC. Please provide a
|
||||||
|
valid invoice for {pn(this.props.data.invoiceAmount)} Satoshis.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<TextField
|
||||||
|
label={"Payout Lightning Invoice"}
|
||||||
|
required
|
||||||
|
inputProps={{
|
||||||
|
style: {textAlign:"center"}
|
||||||
|
}}
|
||||||
|
multiline
|
||||||
|
onChange={this.handleInputInvoiceChanged}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button variant='contained' color='primary' onClick={this.handleClickSubmitInvoiceButton}>Submit</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showWaitingForEscrow(){
|
||||||
|
return(
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>Your invoice looks good!</b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="body2" variant="body2" align="left">
|
||||||
|
<p>We are waiting for the seller to deposit the full trade BTC amount
|
||||||
|
into the escrow.</p>
|
||||||
|
<p> Just hang on for a moment. If the seller does not deposit,
|
||||||
|
you will get your bond back automatically.</p>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showWaitingForBuyerInvoice(){
|
||||||
|
return(
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>The trade collateral is locked! :D </b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="body2" variant="body2" align="left">
|
||||||
|
<p> We are waiting for the buyer to post a lightning invoice. Once
|
||||||
|
he does, you will be able to directly communicate the fiat payment
|
||||||
|
details. </p>
|
||||||
|
<p> Just hang on for a moment. If the buyer does not cooperate,
|
||||||
|
you will get back the trade collateral and your bond automatically.</p>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickConfirmButton=()=>{
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
|
||||||
|
body: JSON.stringify({
|
||||||
|
'action': "confirm",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => (this.props.data = data));
|
||||||
|
}
|
||||||
|
handleClickOpenDisputeButton=()=>{
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
|
||||||
|
body: JSON.stringify({
|
||||||
|
'action': "dispute",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => (this.props.data = data));
|
||||||
|
}
|
||||||
|
|
||||||
|
showFiatSentButton(){
|
||||||
|
return(
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button defaultValue="confirm" variant='contained' color='primary' onClick={this.handleClickConfirmButton}>Confirm {this.props.data.currencyCode} sent</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showFiatReceivedButton(){
|
||||||
|
// TODO, show alert and ask for double confirmation (Have you check you received the fiat? Confirming fiat received settles the trade.)
|
||||||
|
// Ask for double confirmation.
|
||||||
|
return(
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button defaultValue="confirm" variant='contained' color='primary' onClick={this.handleClickConfirmButton}>Confirm {this.props.data.currencyCode} received</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showOpenDisputeButton(){
|
||||||
|
// TODO, show alert about how opening a dispute might involve giving away personal data and might mean losing the bond. Ask for double confirmation.
|
||||||
|
return(
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Button defaultValue="dispute" variant='contained' onClick={this.handleClickOpenDisputeButton}>Open Dispute</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showChat(sendFiatButton, receivedFiatButton, openDisputeButton){
|
||||||
|
return(
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="subtitle1" variant="subtitle1">
|
||||||
|
<b>Chatting with {this.props.data.isMaker ? this.props.data.takerNick : this.props.data.makerNick}</b>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="left">
|
||||||
|
{this.props.data.isSeller ?
|
||||||
|
<Typography component="body2" variant="body2">
|
||||||
|
Say hi to your peer robot! Be helpful and concise. Let him know how to send you {this.props.data.currencyCode}.
|
||||||
|
</Typography>
|
||||||
|
:
|
||||||
|
<Typography component="body2" variant="body2">
|
||||||
|
Say hi to your peer robot! Ask for payment details and click 'Confirm {this.props.data.currencyCode} sent' as soon as you send the payment.
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} style={{ width:330, height:360}}>
|
||||||
|
CHAT PLACEHOLDER
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
{sendFiatButton ? this.showFiatSentButton() : ""}
|
||||||
|
{receivedFiatButton ? this.showFiatReceivedButton() : ""}
|
||||||
|
{openDisputeButton ? this.showOpenDisputeButton() : ""}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showFiatReceivedButton(){
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// showOpenDisputeButton(){
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// showRateSelect(){
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1} style={{ width:330}}>
|
||||||
|
<Grid item xs={12} align="center">
|
||||||
|
<Typography component="h5" variant="h5">
|
||||||
|
TradeBox
|
||||||
|
</Typography>
|
||||||
|
<Paper elevation={12} style={{ padding: 8,}}>
|
||||||
|
{/* Maker and taker Bond request */}
|
||||||
|
{this.props.data.bondInvoice ? this.showQRInvoice() : ""}
|
||||||
|
|
||||||
|
{/* Waiting for taker and taker bond request */}
|
||||||
|
{this.props.data.isMaker & this.props.data.statusCode == 1 ? this.showMakerWait() : ""}
|
||||||
|
{this.props.data.isMaker & this.props.data.statusCode == 3 ? this.showTakerFound() : ""}
|
||||||
|
|
||||||
|
{/* Send Invoice (buyer) and deposit collateral (seller) */}
|
||||||
|
{this.props.data.isSeller & this.props.data.escrowInvoice != null ? this.showEscrowQRInvoice() : ""}
|
||||||
|
{this.props.data.isBuyer & this.props.data.invoiceAmount != null ? this.showInputInvoice() : ""}
|
||||||
|
{this.props.data.isBuyer & this.props.data.statusCode == 7 ? this.showWaitingForEscrow() : ""}
|
||||||
|
{this.props.data.isSeller & this.props.data.statusCode == 8 ? this.showWaitingForBuyerInvoice() : ""}
|
||||||
|
|
||||||
|
{/* In Chatroom - showChat(showSendButton, showReveiceButton, showDisputeButton) */}
|
||||||
|
{this.props.data.isBuyer & this.props.data.statusCode == 9 ? this.showChat(true,false,true) : ""}
|
||||||
|
{this.props.data.isSeller & this.props.data.statusCode == 9 ? this.showChat(false,false,true) : ""}
|
||||||
|
{this.props.data.isBuyer & this.props.data.statusCode == 10 ? this.showChat(false,false,true) : ""}
|
||||||
|
{this.props.data.isSeller & this.props.data.statusCode == 10 ? this.showChat(false,true,true) : ""}
|
||||||
|
|
||||||
|
{/* Trade Finished */}
|
||||||
|
{this.props.data.isSeller & this.props.data.statusCode > 12 & this.props.data.statusCode < 15 ? this.showRateSelect() : ""}
|
||||||
|
{/* TODO */}
|
||||||
|
{/* */}
|
||||||
|
{/* */}
|
||||||
|
|
||||||
|
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Button , Grid, Typography, TextField, ButtonGroup} from "@material-ui/core"
|
import { Button , Grid, Typography, TextField, ButtonGroup} from "@mui/material"
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import Image from 'material-ui-image'
|
import Image from 'material-ui-image'
|
||||||
|
|
||||||
@ -24,9 +24,9 @@ export default class UserGenPage extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
token: this.genBase62Token(32),
|
token: this.genBase62Token(34),
|
||||||
};
|
};
|
||||||
this.getGeneratedUser();
|
this.getGeneratedUser(this.state.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort of cryptographically strong function to generate Base62 token client-side
|
// sort of cryptographically strong function to generate Base62 token client-side
|
||||||
@ -40,8 +40,8 @@ export default class UserGenPage extends Component {
|
|||||||
.substring(0, length);
|
.substring(0, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
getGeneratedUser() {
|
getGeneratedUser(token) {
|
||||||
fetch('/api/usergen' + '?token=' + this.state.token)
|
fetch('/api/user' + '?token=' + token)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -60,7 +60,7 @@ export default class UserGenPage extends Component {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')},
|
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')},
|
||||||
};
|
};
|
||||||
fetch("/api/usergen", requestOptions)
|
fetch("/api/user", requestOptions)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => console.log(data));
|
.then((data) => console.log(data));
|
||||||
}
|
}
|
||||||
@ -72,7 +72,7 @@ export default class UserGenPage extends Component {
|
|||||||
handleAnotherButtonPressed=(e)=>{
|
handleAnotherButtonPressed=(e)=>{
|
||||||
this.delGeneratedUser()
|
this.delGeneratedUser()
|
||||||
this.setState({
|
this.setState({
|
||||||
token: this.genBase62Token(32),
|
token: this.genBase62Token(34),
|
||||||
})
|
})
|
||||||
this.reload_for_csrf_to_work();
|
this.reload_for_csrf_to_work();
|
||||||
}
|
}
|
||||||
@ -82,7 +82,7 @@ export default class UserGenPage extends Component {
|
|||||||
this.setState({
|
this.setState({
|
||||||
token: e.target.value,
|
token: e.target.value,
|
||||||
})
|
})
|
||||||
this.getGeneratedUser();
|
this.getGeneratedUser(e.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TO FIX CSRF TOKEN IS NOT UPDATED UNTIL WINDOW IS RELOADED
|
// TO FIX CSRF TOKEN IS NOT UPDATED UNTIL WINDOW IS RELOADED
|
||||||
@ -137,13 +137,13 @@ export default class UserGenPage extends Component {
|
|||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
||||||
<Button color='primary' to='/make/' component={Link}>Make Order</Button>
|
<Button color='primary' to='/make/' component={Link}>Make Order</Button>
|
||||||
<Button to='/home' component={Link}>INFO</Button>
|
<Button color='inherit' to='/home' component={Link}>INFO</Button>
|
||||||
<Button color='secondary' to='/book/' component={Link}>View Book</Button>
|
<Button color='secondary' to='/book/' component={Link}>View Book</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Typography component="h5" variant="h5">
|
<Typography component="h5" variant="h5">
|
||||||
Easy and Private Lightning peer-to-peer Exchange
|
Simple and Private Lightning peer-to-peer Exchange
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import React, { Component } from "react";
|
|
||||||
|
|
||||||
export default class WaitingRoomPage extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <p>This is the waiting room</p>;
|
|
||||||
}
|
|
||||||
}
|
|
8
frontend/static/assets/currencies.json
Normal file
8
frontend/static/assets/currencies.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"1":"USD",
|
||||||
|
"2":"EUR",
|
||||||
|
"3":"ETH",
|
||||||
|
"4":"AUD",
|
||||||
|
"5":"BRL",
|
||||||
|
"6":"CAD"
|
||||||
|
}
|
@ -8,5 +8,5 @@ urlpatterns = [
|
|||||||
path('make/', index),
|
path('make/', index),
|
||||||
path('book/', index),
|
path('book/', index),
|
||||||
path('order/<int:orderId>', index),
|
path('order/<int:orderId>', index),
|
||||||
path('wait/', index),
|
path('wait/', index),
|
||||||
]
|
]
|
@ -29,4 +29,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.ts', '.js'],
|
||||||
|
},
|
||||||
};
|
};
|
6
setup.md
6
setup.md
@ -7,6 +7,7 @@
|
|||||||
```
|
```
|
||||||
pip install virtualenvwrapper
|
pip install virtualenvwrapper
|
||||||
pip install python-decouple
|
pip install python-decouple
|
||||||
|
pip install ring
|
||||||
```
|
```
|
||||||
|
|
||||||
### Add to .bashrc
|
### Add to .bashrc
|
||||||
@ -66,7 +67,12 @@ npm install react-router-dom@5.2.0
|
|||||||
npm install @material-ui/icons
|
npm install @material-ui/icons
|
||||||
npm install material-ui-image
|
npm install material-ui-image
|
||||||
npm install @mui/system @emotion/react @emotion/styled
|
npm install @mui/system @emotion/react @emotion/styled
|
||||||
|
npm install react-native
|
||||||
|
npm install react-native-svg
|
||||||
|
npm install react-qr-code
|
||||||
|
npm install @mui/material
|
||||||
```
|
```
|
||||||
|
Note we are using mostly MaterialUI V5, but Image loading from V4 extentions (so both V4 and V5 are needed)
|
||||||
|
|
||||||
### Launch the React render
|
### Launch the React render
|
||||||
from frontend/ directory
|
from frontend/ directory
|
||||||
|
Loading…
Reference in New Issue
Block a user