diff --git a/.env-sample b/.env-sample
index 54d6637c..3eb14fbb 100644
--- a/.env-sample
+++ b/.env-sample
@@ -6,12 +6,15 @@ LND_GRPC_HOST='127.0.0.1:10009'
REDIS_URL=''
-# Market price public API
-MARKET_PRICE_API = 'https://blockchain.info/ticker'
+# List of market price public APIs. If the currency is available in more than 1 API, will use median price.
+MARKET_PRICE_APIS = https://blockchain.info/ticker, https://api.yadio.io/exrates/BTC
-# Host e.g. robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion
+# Host e.g. robosats.com
HOST_NAME = ''
+# e.g. robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion
+ONION_LOCATION = ''
+
# Trade fee in percentage %
FEE = 0.002
# Bond size in percentage %
diff --git a/.gitignore b/.gitignore
index 07c2094f..f3a0f6bc 100755
--- a/.gitignore
+++ b/.gitignore
@@ -639,6 +639,9 @@ FodyWeavers.xsd
*migrations*
frontend/static/frontend/main*
+# Celery
+django
+
# robosats
frontend/static/assets/avatars*
api/lightning/lightning*
diff --git a/README.md b/README.md
index 8dc24bcb..71503250 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,46 @@
-# RoboSats: Buy and sell non-KYC Satoshis.
-## What is RoboSats?
+## RoboSats - Buy and sell Satoshis Privately.
+[![release](https://img.shields.io/badge/release-v0.1.0%20MVP-orange)](https://github.com/Reckless-Satoshi/robosats/releases)
+[![AGPL-3.0 license](https://img.shields.io/badge/license-AGPL--3.0-blue)](https://github.com/Reckless-Satoshi/robosats/blob/main/LICENSE)
+[![Telegram](https://img.shields.io/badge/chat-telegram-brightgreen)](https://t.me/robosats)
-RoboSats is a simple and private way to exchange bitcoin for national currencies. Robosats aims to simplify the peer-to-peer experience and uses lightning hodl invoices to minimize the trust needed to trade. In addition, your Robotic Satoshi will help you stick to best privacy practices.
+RoboSats is a simple and private way to exchange bitcoin for national currencies. Robosats simplifies the peer-to-peer user experience and uses lightning hold invoices to minimize custody and trust requirements. The deterministically generated avatars help users stick to best privacy practices.
## Try it out
**Bitcoin mainnet:**
-- Tor: robosatsbkpis32grrxz7vliwjuivdmsyjx4d7zrlffo3nul44ck5sad.onion
-- Url: robosats.org (Not active)
+- Tor: robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion (Coming soon)
+- Url: robosats.com (Coming soon)
- Version: v0.0.0 (Last stable)
**Bitcoin testnet:**
-- Tor: robotescktg6eqthfvatugczhzo3rj5zzk7rrkp6n5pa5qrz2mdikwid.onion
-- Url: testnet.robosats.org (Not active)
-- Commit height: v0.0.0 Latest commit.
+- Tor: robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion (Active - On Dev Node)
+- Url: testnet.robosats.com (Coming soon)
+- Commit height: Latest commit.
+
+*Always use [Tor Browser](https://www.torproject.org/download/) and .onion for best anonymity.*
+
+## How it works
+
+Alice wants to buy satoshis privately:
+1. Alice generates an avatar (AdequateAlice01) using her private random token.
+2. Alice stores safely the token in case she needs to recover AdequateAlice01 in the future.
+3. Alice makes a new order and locks a small hold invoice to publish it (maker bond).
+4. Bob wants to sell satoshis, sees Alice's order in the book and takes it.
+5. Bob scans a small hold invoice as his taker bond. The contract is final.
+6. Bob posts the traded satoshis with a hold invoice. While Alice submits her payout invoice.
+7. On a private chat, Bob tells Alice how to send him fiat.
+8. Alice pays Bob, then they confirm the fiat has been sent and received.
+9. Bob's trade hold invoice is charged and the satoshis are sent to Alice.
+10. Bob and Alice's bonds return automatically, since they complied by the rules.
+11. The bonds would be charged (lost) in case of unilateral cancellation or cheating (lost dispute).
-*Use [Tor Browser](https://www.torproject.org/download/) and .onion for best anonymity.*
## Contribute to the Robotic Satoshis Open Source Project
See [CONTRIBUTING.md](CONTRIBUTING.md)
+## Original idea
+The concept of a simple custody-minimized lightning exchange using hold invoices is heavily inspired by [P2PLNBOT](https://github.com/grunch/p2plnbot) by @grunch
+
## License
-RoboSats is released under the terms of the AGPL3.0 license. See [LICENSE](LICENSE) for more details.
+The Robotic Satoshis Open Source Project is released under the terms of the AGPL3.0 license. See [LICENSE](LICENSE) for more details.
diff --git a/api/admin.py b/api/admin.py
index 9d8ab64f..ff7f2c68 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -2,7 +2,7 @@ from django.contrib import admin
from django_admin_relation_links import AdminChangeLinksMixin
from django.contrib.auth.models import Group, User
from django.contrib.auth.admin import UserAdmin
-from .models import Order, LNPayment, Profile, MarketTick
+from .models import Order, LNPayment, Profile, MarketTick, Currency
admin.site.unregister(Group)
admin.site.unregister(User)
@@ -22,27 +22,34 @@ class EUserAdmin(UserAdmin):
def avatar_tag(self, obj):
return obj.profile.avatar_tag()
+
@admin.register(Order)
class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
- list_display = ('id','type','maker_link','taker_link','status','amount','currency','t0_satoshis','is_disputed','is_fiat_sent','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link')
+ list_display = ('id','type','maker_link','taker_link','status','amount','currency_link','t0_satoshis','is_disputed','is_fiat_sent','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link')
list_display_links = ('id','type')
- change_links = ('maker','taker','buyer_invoice','maker_bond','taker_bond','trade_escrow')
+ change_links = ('maker','taker','currency','buyer_invoice','maker_bond','taker_bond','trade_escrow')
list_filter = ('is_disputed','is_fiat_sent','type','currency','status')
@admin.register(LNPayment)
class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
- list_display = ('id','concept','status','num_satoshis','type','invoice','expires_at','sender_link','receiver_link')
- list_display_links = ('id','concept')
+ list_display = ('hash','concept','status','num_satoshis','type','expires_at','sender_link','receiver_link','order_made','order_taken','order_escrow','order_paid')
+ list_display_links = ('hash','concept','order_made','order_taken','order_escrow','order_paid')
change_links = ('sender','receiver')
list_filter = ('type','concept','status')
@admin.register(Profile)
class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
- list_display = ('avatar_tag','id','user_link','total_ratings','avg_rating','num_disputes','lost_disputes')
+ list_display = ('avatar_tag','id','user_link','total_contracts','total_ratings','avg_rating','num_disputes','lost_disputes')
list_display_links = ('avatar_tag','id')
change_links =['user']
readonly_fields = ['avatar_tag']
+@admin.register(Currency)
+class CurrencieAdmin(admin.ModelAdmin):
+ list_display = ('id','currency','exchange_rate','timestamp')
+ list_display_links = ('id','currency')
+ readonly_fields = ('currency','exchange_rate','timestamp')
+
@admin.register(MarketTick)
class MarketTickAdmin(admin.ModelAdmin):
list_display = ('timestamp','price','volume','premium','currency','fee')
diff --git a/api/lightning/node.py b/api/lightning/node.py
index 76d6acb3..4f6cb348 100644
--- a/api/lightning/node.py
+++ b/api/lightning/node.py
@@ -28,6 +28,10 @@ class LNNode():
invoicesstub = invoicesstub.InvoicesStub(channel)
routerstub = routerstub.RouterStub(channel)
+ lnrpc = lnrpc
+ invoicesrpc = invoicesrpc
+ routerrpc = routerrpc
+
payment_failure_context = {
0: "Payment isn't failed (yet)",
1: "There are more routes to try, but the payment timeout was exceeded.",
diff --git a/api/logics.py b/api/logics.py
index c8da042a..7059ea10 100644
--- a/api/logics.py
+++ b/api/logics.py
@@ -1,16 +1,17 @@
-from datetime import time, timedelta
+from datetime import timedelta
from django.utils import timezone
-from .lightning.node import LNNode
+from api.lightning.node import LNNode
-from .models import Order, LNPayment, MarketTick, User
+from api.models import Order, LNPayment, MarketTick, User, Currency
from decouple import config
-from .utils import get_exchange_rate
+
+from api.tasks import follow_send_payment
import math
+import ast
FEE = float(config('FEE'))
BOND_SIZE = float(config('BOND_SIZE'))
-MARKET_PRICE_API = config('MARKET_PRICE_API')
ESCROW_USERNAME = config('ESCROW_USERNAME')
PENALTY_TIMEOUT = int(config('PENALTY_TIMEOUT'))
@@ -30,17 +31,24 @@ FIAT_EXCHANGE_DURATION = int(config('FIAT_EXCHANGE_DURATION'))
class Logics():
def validate_already_maker_or_taker(user):
- '''Checks if the user is already partipant of an order'''
- queryset = Order.objects.filter(maker=user)
+ '''Validates if a use is already not part of an active order'''
+
+ active_order_status = [Order.Status.WFB, Order.Status.PUB, Order.Status.TAK,
+ Order.Status.WF2, Order.Status.WFE, Order.Status.WFI,
+ Order.Status.CHA, Order.Status.FSE, Order.Status.DIS,
+ Order.Status.WFR]
+ '''Checks if the user is already partipant of an active order'''
+ queryset = Order.objects.filter(maker=user, status__in=active_order_status)
if queryset.exists():
- return False, {'bad_request':'You are already maker of an order'}
- queryset = Order.objects.filter(taker=user)
+ return False, {'bad_request':'You are already maker of an active order'}
+
+ queryset = Order.objects.filter(taker=user, status__in=active_order_status)
if queryset.exists():
- return False, {'bad_request':'You are already taker of an order'}
+ return False, {'bad_request':'You are already taker of an active order'}
return True, None
def validate_order_size(order):
- '''Checks if order is withing limits at t0'''
+ '''Validates if order is withing limits in satoshis at t0'''
if order.t0_satoshis > 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:
@@ -55,7 +63,7 @@ class Logics():
else:
order.taker = user
order.status = Order.Status.TAK
- order.expires_at = timezone.now() + timedelta(minutes=EXP_TAKER_BOND_INVOICE)
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.TAK])
order.save()
return True, None
@@ -74,7 +82,7 @@ class Logics():
if order.is_explicit:
satoshis_now = order.satoshis
else:
- exchange_rate = get_exchange_rate(Order.currency_dict[str(order.currency)])
+ exchange_rate = float(order.currency.exchange_rate)
premium_rate = exchange_rate * (1+float(order.premium)/100)
satoshis_now = (float(order.amount) / premium_rate) * 100*1000*1000
@@ -82,45 +90,177 @@ class Logics():
def price_and_premium_now(order):
''' computes order premium live '''
- exchange_rate = get_exchange_rate(Order.currency_dict[str(order.currency)])
+ exchange_rate = float(order.currency.exchange_rate)
if not order.is_explicit:
premium = order.premium
price = exchange_rate * (1+float(premium)/100)
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
- premium = int(premium*100) # 2 decimals left
+ premium = int(premium*10000)/100 # 2 decimals left
price = order_rate
- significant_digits = 6
+ significant_digits = 5
price = round(price, significant_digits - int(math.floor(math.log10(abs(price)))) - 1)
return price, premium
- def order_expires(order):
- ''' General case when time runs out. Only
- used when the maker does not lock a publishing bond'''
- order.status = Order.Status.EXP
- order.maker = None
- order.taker = None
- order.save()
+ @classmethod
+ def order_expires(cls, order):
+ ''' General cases when time runs out.'''
- def kick_taker(order):
- ''' The taker did not lock the taker_bond. Now he has to go'''
- # Add a time out to the taker
- profile = order.taker.profile
- profile.penalty_expiration = timezone.now() + timedelta(seconds=PENALTY_TIMEOUT)
- profile.save()
+ # Do not change order status if an order in any with
+ # any of these status is sent to expire here
+ do_nothing = [Order.Status.DEL, Order.Status.UCA,
+ Order.Status.EXP, Order.Status.FSE,
+ Order.Status.DIS, Order.Status.CCA,
+ Order.Status.PAY, Order.Status.SUC,
+ Order.Status.FAI, Order.Status.MLD,
+ Order.Status.TLD]
- # Delete the taker_bond payment request, and make order public again
- if LNNode.cancel_return_hold_invoice(order.taker_bond.payment_hash):
- order.status = Order.Status.PUB
- order.taker = None
- order.taker_bond = None
- order.expires_at = timezone.now() + timedelta(hours=PUBLIC_ORDER_DURATION) ## TO FIX. Restore the remaining order durantion, not all of it!
+ if order.status in do_nothing:
+ return False
+
+ elif order.status == Order.Status.WFB:
+ order.status = Order.Status.EXP
+ cls.cancel_bond(order.maker_bond)
order.save()
return True
+
+ elif order.status == Order.Status.PUB:
+ cls.return_bond(order.maker_bond)
+ order.status = Order.Status.EXP
+ order.save()
+ return True
+
+ elif order.status == Order.Status.TAK:
+ cls.cancel_bond(order.taker_bond)
+ cls.kick_taker(order)
+ return True
+
+ elif order.status == Order.Status.WF2:
+ '''Weird case where an order expires and both participants
+ did not proceed with the contract. Likely the site was
+ down or there was a bug. Still bonds must be charged
+ to avoid service DDOS. '''
+
+ cls.settle_bond(order.maker_bond)
+ cls.settle_bond(order.taker_bond)
+ cls.cancel_escrow(order)
+ order.status = Order.Status.EXP
+ order.save()
+ return True
+
+ elif order.status == Order.Status.WFE:
+ maker_is_seller = cls.is_seller(order, order.maker)
+ # If maker is seller, settle the bond and order goes to expired
+ if maker_is_seller:
+ cls.settle_bond(order.maker_bond)
+ cls.return_bond(order.taker_bond)
+ cls.cancel_escrow(order)
+ order.status = Order.Status.EXP
+ order.save()
+ return True
+
+ # If maker is buyer, settle the taker's bond order goes back to public
+ else:
+ cls.settle_bond(order.taker_bond)
+ cls.cancel_escrow(order)
+ order.taker = None
+ order.taker_bond = None
+ order.trade_escrow = None
+ cls.publish_order(order)
+ return True
+
+ elif order.status == Order.Status.WFI:
+ # The trade could happen without a buyer invoice. However, this user
+ # is likely AFK; will probably desert the contract as well.
+
+ maker_is_buyer = cls.is_buyer(order, order.maker)
+ # If maker is buyer, settle the bond and order goes to expired
+ if maker_is_buyer:
+ cls.settle_bond(order.maker_bond)
+ cls.return_bond(order.taker_bond)
+ cls.return_escrow(order)
+ order.status = Order.Status.EXP
+ order.save()
+ return True
+
+ # If maker is seller settle the taker's bond, order goes back to public
+ else:
+ cls.settle_bond(order.taker_bond)
+ cls.return_escrow(order)
+ order.taker = None
+ order.taker_bond = None
+ order.trade_escrow = None
+ cls.publish_order(order)
+ return True
+
+ elif order.status == Order.Status.CHA:
+ # Another weird case. The time to confirm 'fiat sent' expired. Yet no dispute
+ # was opened. Hint: a seller-scammer could persuade a buyer to not click "fiat
+ # sent", we assume this is a dispute case by default.
+ cls.open_dispute(order)
+ return True
+
+ @classmethod
+ def kick_taker(cls, order):
+ ''' The taker did not lock the taker_bond. Now he has to go'''
+ # Add a time out to the taker
+ if order.taker:
+ profile = order.taker.profile
+ profile.penalty_expiration = timezone.now() + timedelta(seconds=PENALTY_TIMEOUT)
+ profile.save()
+
+ # Make order public again
+ order.taker = None
+ order.taker_bond = None
+ cls.publish_order(order)
+ return True
+
+ @classmethod
+ def open_dispute(cls, order, user=None):
+
+ # Always settle the escrow during a dispute (same as with 'Fiat Sent')
+ # Dispute winner will have to submit a new invoice.
+
+ if not order.trade_escrow.status == LNPayment.Status.SETLED:
+ cls.settle_escrow(order)
+
+ order.is_disputed = True
+ order.status = Order.Status.DIS
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.DIS])
+ order.save()
+
+ # User could be None if a dispute is open automatically due to weird expiration.
+ if not user == None:
+ profile = user.profile
+ profile.num_disputes = profile.num_disputes + 1
+ profile.orders_disputes_started = list(profile.orders_disputes_started).append(str(order.id))
+ profile.save()
+
+ return True, None
+
+ def dispute_statement(order, user, statement):
+ ''' Updates the dispute statements in DB'''
+ if not order.status == Order.Status.DIS:
+ return False, {'bad_request':'Only orders in dispute accept a dispute statements'}
+
+ if len(statement) > 5000:
+ return False, {'bad_statement':'The statement is longer than 5000 characters'}
+
+ if order.maker == user:
+ order.maker_statement = statement
+ else:
+ order.taker_statement = statement
+
+ # If both statements are in, move status to wait for dispute resolution
+ if order.maker_statement != None and order.taker_statement != None:
+ order.status = Order.Status.WFR
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.WFR])
+
+ order.save()
+ return True, None
@classmethod
def buyer_invoice_amount(cls, order, user):
@@ -168,14 +308,14 @@ class Logics():
# If the order status is 'Waiting for invoice'. Move forward to 'chat'
if order.status == Order.Status.WFI:
order.status = Order.Status.CHA
- order.expires_at = timezone.now() + timedelta(hours=FIAT_EXCHANGE_DURATION)
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA])
# If the order status is 'Waiting for both'. Move forward to 'waiting for escrow'
if order.status == Order.Status.WF2:
# If the escrow is lock move to Chat.
if order.trade_escrow.status == LNPayment.Status.LOCKED:
order.status = Order.Status.CHA
- order.expires_at = timezone.now() + timedelta(hours=FIAT_EXCHANGE_DURATION)
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA])
else:
order.status = Order.Status.WFE
@@ -185,16 +325,18 @@ class Logics():
def add_profile_rating(profile, rating):
''' adds a new rating to a user profile'''
+ # TODO Unsafe, does not update ratings, it adds more ratings everytime a new rating is clicked.
profile.total_ratings = profile.total_ratings + 1
latest_ratings = profile.latest_ratings
- if len(latest_ratings) <= 1:
+ if latest_ratings == None:
profile.latest_ratings = [rating]
profile.avg_rating = rating
else:
- latest_ratings = list(latest_ratings).append(rating)
+ latest_ratings = ast.literal_eval(latest_ratings)
+ latest_ratings.append(rating)
profile.latest_ratings = latest_ratings
- profile.avg_rating = sum(latest_ratings) / len(latest_ratings)
+ profile.avg_rating = sum(list(map(int, latest_ratings))) / len(latest_ratings) # Just an average, but it is a list of strings. Has to be converted to int.
profile.save()
@@ -217,7 +359,6 @@ class Logics():
'''The order never shows up on the book and order
status becomes "cancelled". That's it.'''
if order.status == Order.Status.WFB and order.maker == user:
- order.maker = None
order.status = Order.Status.UCA
order.save()
return True, None
@@ -227,8 +368,7 @@ class Logics():
on the LN node and order book. TODO Only charge a small part of the bond (requires maker submitting an invoice)'''
elif order.status == Order.Status.PUB and order.maker == user:
#Settle the maker bond (Maker loses the bond for cancelling public order)
- if cls.settle_maker_bond(order):
- order.maker = None
+ if cls.settle_bond(order.maker_bond):
order.status = Order.Status.UCA
order.save()
return True, None
@@ -238,13 +378,8 @@ class Logics():
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.profile.save()
-
- order.taker = None
- order.status = Order.Status.PUB
- order.save()
-
+ cls.cancel_bond(order.taker_bond)
+ cls.kick_taker(order)
return True, None
# 4) When taker or maker cancel after bond (before escrow)
@@ -256,27 +391,24 @@ class Logics():
'''The order into cancelled status if maker cancels.'''
elif order.status > Order.Status.PUB and order.status < Order.Status.CHA and order.maker == user:
#Settle the maker bond (Maker loses the bond for canceling an ongoing trade)
- valid = cls.settle_maker_bond(order)
+ valid = cls.settle_bond(order.maker_bond)
if valid:
- order.maker = None
order.status = Order.Status.UCA
order.save()
return True, None
# 4.b) When taker cancel after bond (before escrow)
'''The order into cancelled status if maker cancels.'''
- elif order.status > Order.Status.TAK and order.status < Order.Status.CHA and order.taker == user:
+ elif order.status in [Order.Status.WF2, Order.Status.WFE] and order.taker == user:
# Settle the maker bond (Maker loses the bond for canceling an ongoing trade)
- valid = cls.settle_taker_bond(order)
+ valid = cls.settle_bond(order.taker_bond)
if valid:
order.taker = None
- order.status = Order.Status.PUB
- # order.taker_bond = None # TODO fix this, it overrides the information about the settled taker bond. Might make admin tasks hard.
- order.save()
+ cls.publish_order(order)
return True, None
# 5) When trade collateral has been posted (after escrow)
- '''Always goes to cancelled status. Collaboration is needed.
+ '''Always goes to cancelled status. Collaboration is needed.
When a user asks for cancel, 'order.is_pending_cancel' goes True.
When the second user asks for cancel. Order is totally cancelled.
Has a small cost for both parties to prevent node DDOS.'''
@@ -284,15 +416,20 @@ class Logics():
else:
return False, {'bad_request':'You cannot cancel this order'}
+ def publish_order(order):
+ order.status = Order.Status.PUB
+ order.expires_at = order.created_at + timedelta(seconds=Order.t_to_expire[Order.Status.PUB])
+ order.save()
+ return
+
@classmethod
def is_maker_bond_locked(cls, order):
- if LNNode.validate_hold_invoice_locked(order.maker_bond.payment_hash):
+ if order.maker_bond.status == LNPayment.Status.LOCKED:
+ return True
+ elif LNNode.validate_hold_invoice_locked(order.maker_bond.payment_hash):
order.maker_bond.status = LNPayment.Status.LOCKED
order.maker_bond.save()
- order.status = Order.Status.PUB
- # With the bond confirmation the order is extended 'public_order_duration' hours
- order.expires_at = timezone.now() + timedelta(hours=PUBLIC_ORDER_DURATION)
- order.save()
+ cls.publish_order(order)
return True
return False
@@ -338,22 +475,38 @@ class Logics():
return True, {'bond_invoice':hold_payment['invoice'], 'bond_satoshis':bond_satoshis}
@classmethod
- def is_taker_bond_locked(cls, order):
- if LNNode.validate_hold_invoice_locked(order.taker_bond.payment_hash):
+ def finalize_contract(cls, order):
+ ''' When the taker locks the taker_bond
+ the contract is final '''
+
# THE TRADE AMOUNT IS FINAL WITH THE CONFIRMATION OF THE TAKER BOND!
- # (This is the last update to "last_satoshis", it becomes the escrow amount next!)
+ # (This is the last update to "last_satoshis", it becomes the escrow amount next)
order.last_satoshis = cls.satoshis_now(order)
order.taker_bond.status = LNPayment.Status.LOCKED
order.taker_bond.save()
+ # Both users profiles are added one more contract // Unsafe can add more than once.
+ order.maker.profile.total_contracts += 1
+ order.taker.profile.total_contracts += 1
+ order.maker.profile.save()
+ order.taker.profile.save()
+
# Log a market tick
MarketTick.log_a_tick(order)
# With the bond confirmation the order is extended 'public_order_duration' hours
- order.expires_at = timezone.now() + timedelta(minutes=INVOICE_AND_ESCROW_DURATION)
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.WF2])
order.status = Order.Status.WF2
order.save()
return True
+
+ @classmethod
+ def is_taker_bond_locked(cls, order):
+ if order.taker_bond.status == LNPayment.Status.LOCKED:
+ return True
+ elif LNNode.validate_hold_invoice_locked(order.taker_bond.payment_hash):
+ cls.finalize_contract(order)
+ return True
return False
@classmethod
@@ -361,12 +514,12 @@ class Logics():
# Do not gen and kick out the taker if order is older than expiry time
if order.expires_at < timezone.now():
+ cls.cancel_bond(order.taker_bond)
cls.kick_taker(order)
return False, {'bad_request':'Invoice expired. You did not confirm taking the order in time.'}
# Do not gen if a taker invoice exist. Do not return if it is already locked. Return the old one if still waiting.
if order.taker_bond:
- # Check if status is INVGEN and still not expired
if cls.is_taker_bond_locked(order):
return False, None
elif order.taker_bond.status == LNPayment.Status.INVGEN:
@@ -376,7 +529,8 @@ class Logics():
order.last_satoshis = cls.satoshis_now(order)
bond_satoshis = int(order.last_satoshis * BOND_SIZE)
pos_text = 'Buying' if cls.is_buyer(order, user) else 'Selling'
- description = f"RoboSats - Taking 'Order {order.id}' {pos_text} BTC for {order.amount} - This is a taker bond, it will freeze in your wallet temporarily and automatically return. It will be charged if you cheat or cancel."
+ description = (f"RoboSats - Taking 'Order {order.id}' {pos_text} BTC for {str(float(order.amount)) + Currency.currency_dict[str(order.currency.currency)]}"
+ + " - This is a taker bond, it will freeze in your wallet temporarily and automatically return. It will be charged if you cheat or cancel.")
# Gen hold Invoice
hold_payment = LNNode.gen_hold_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
@@ -395,23 +549,29 @@ class Logics():
created_at = hold_payment['created_at'],
expires_at = hold_payment['expires_at'])
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.TAK])
order.save()
return True, {'bond_invoice': hold_payment['invoice'], 'bond_satoshis': bond_satoshis}
+ def trade_escrow_received(order):
+ ''' Moves the order forward'''
+ # If status is 'Waiting for both' move to Waiting for invoice
+ if order.status == Order.Status.WF2:
+ order.status = Order.Status.WFI
+ # If status is 'Waiting for invoice' move to Chat
+ elif order.status == Order.Status.WFE:
+ order.status = Order.Status.CHA
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA])
+ order.save()
@classmethod
def is_trade_escrow_locked(cls, order):
- if LNNode.validate_hold_invoice_locked(order.trade_escrow.payment_hash):
+ if order.trade_escrow.status == LNPayment.Status.LOCKED:
+ return True
+ elif LNNode.validate_hold_invoice_locked(order.trade_escrow.payment_hash):
order.trade_escrow.status = LNPayment.Status.LOCKED
order.trade_escrow.save()
- # If status is 'Waiting for both' move to Waiting for invoice
- if order.status == Order.Status.WF2:
- order.status = Order.Status.WFI
- # If status is 'Waiting for invoice' move to Chat
- elif order.status == Order.Status.WFE:
- order.status = Order.Status.CHA
- order.expires_at = timezone.now() + timedelta(hours=FIAT_EXCHANGE_DURATION)
- order.save()
+ cls.trade_escrow_received(order)
return True
return False
@@ -431,7 +591,7 @@ class Logics():
elif order.trade_escrow.status == LNPayment.Status.INVGEN:
return True, {'escrow_invoice':order.trade_escrow.invoice, 'escrow_satoshis':order.trade_escrow.num_satoshis}
- # If there was no taker_bond object yet, generates one
+ # If there was no taker_bond object yet, generate one
escrow_satoshis = order.last_satoshis # Amount was fixed when taker bond was locked
description = f"RoboSats - Escrow amount for '{str(order)}' - The escrow will be released to the buyer once you confirm you received the fiat. It will automatically return if buyer does not confirm the payment."
@@ -463,33 +623,62 @@ class Logics():
order.trade_escrow.save()
return True
- def settle_maker_bond(order):
- ''' Settles the maker bond hold invoice'''
+ def settle_bond(bond):
+ ''' Settles the bond hold invoice'''
# TODO ERROR HANDLING
- if LNNode.settle_hold_invoice(order.maker_bond.preimage):
- order.maker_bond.status = LNPayment.Status.SETLED
- order.maker_bond.save()
+ if LNNode.settle_hold_invoice(bond.preimage):
+ bond.status = LNPayment.Status.SETLED
+ bond.save()
return True
- def settle_taker_bond(order):
- ''' Settles the taker bond hold invoice'''
- # TODO ERROR HANDLING
- if LNNode.settle_hold_invoice(order.taker_bond.preimage):
- order.taker_bond.status = LNPayment.Status.SETLED
- order.taker_bond.save()
+ def return_escrow(order):
+ '''returns the trade escrow'''
+ if LNNode.cancel_return_hold_invoice(order.trade_escrow.payment_hash):
+ order.trade_escrow.status = LNPayment.Status.RETNED
return True
-
+
+ def cancel_escrow(order):
+ '''returns the trade escrow'''
+ # Same as return escrow, but used when the invoice was never LOCKED
+ if LNNode.cancel_return_hold_invoice(order.trade_escrow.payment_hash):
+ order.trade_escrow.status = LNPayment.Status.CANCEL
+ return True
+
def return_bond(bond):
'''returns a bond'''
- if LNNode.cancel_return_hold_invoice(bond.payment_hash):
+ if bond == None:
+ return
+ try:
+ LNNode.cancel_return_hold_invoice(bond.payment_hash)
bond.status = LNPayment.Status.RETNED
return True
+ except Exception as e:
+ if 'invoice already settled' in str(e):
+ bond.status = LNPayment.Status.SETLED
+ return True
+ else:
+ raise e
+
+ def cancel_bond(bond):
+ '''cancel a bond'''
+ # Same as return bond, but used when the invoice was never LOCKED
+ if bond == None:
+ return True
+ try:
+ LNNode.cancel_return_hold_invoice(bond.payment_hash)
+ bond.status = LNPayment.Status.CANCEL
+ return True
+ except Exception as e:
+ if 'invoice already settled' in str(e):
+ bond.status = LNPayment.Status.SETLED
+ return True
+ else:
+ raise e
def pay_buyer_invoice(order):
''' Pay buyer invoice'''
- # TODO ERROR HANDLING
- if LNNode.pay_invoice(order.buyer_invoice.invoice, order.buyer_invoice.num_satoshis):
- return True
+ suceeded, context = follow_send_payment(order.buyer_invoice)
+ return suceeded, context
@classmethod
def confirm_fiat(cls, order, user):
@@ -521,7 +710,7 @@ class Logics():
if is_payed:
order.status = Order.Status.SUC
order.buyer_invoice.status = LNPayment.Status.SUCCED
- order.expires_at = timezone.now() + timedelta(days=1) # One day to rate / see this order.
+ order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.SUC])
order.save()
# RETURN THE BONDS
@@ -542,11 +731,15 @@ class Logics():
# If the trade is finished
if order.status > Order.Status.PAY:
# if maker, rates taker
- if order.maker == user:
+ if order.maker == user and order.maker_rated == False:
cls.add_profile_rating(order.taker.profile, rating)
+ order.maker_rated = True
+ order.save()
# if taker, rates maker
- if order.taker == user:
+ if order.taker == user and order.taker_rated == False:
cls.add_profile_rating(order.maker.profile, rating)
+ order.taker_rated = True
+ order.save()
else:
return False, {'bad_request':'You cannot rate your counterparty yet.'}
diff --git a/api/management/commands/clean_orders.py b/api/management/commands/clean_orders.py
new file mode 100644
index 00000000..033784c5
--- /dev/null
+++ b/api/management/commands/clean_orders.py
@@ -0,0 +1,42 @@
+from django.core.management.base import BaseCommand, CommandError
+
+import time
+from api.models import Order
+from api.logics import Logics
+from django.utils import timezone
+
+class Command(BaseCommand):
+ help = 'Follows all active hold invoices'
+
+ # def add_arguments(self, parser):
+ # parser.add_argument('debug', nargs='+', type=boolean)
+
+ def handle(self, *args, **options):
+ ''' Continuously checks order expiration times for 1 hour. If order
+ has expires, it calls the logics module for expiration handling.'''
+
+ do_nothing = [Order.Status.DEL, Order.Status.UCA,
+ Order.Status.EXP, Order.Status.FSE,
+ Order.Status.DIS, Order.Status.CCA,
+ Order.Status.PAY, Order.Status.SUC,
+ Order.Status.FAI, Order.Status.MLD,
+ Order.Status.TLD]
+
+ while True:
+ time.sleep(5)
+
+ queryset = Order.objects.exclude(status__in=do_nothing)
+ queryset = queryset.filter(expires_at__lt=timezone.now()) # expires at lower than now
+
+ debug = {}
+ debug['num_expired_orders'] = len(queryset)
+ debug['expired_orders'] = []
+
+ for idx, order in enumerate(queryset):
+ context = str(order)+ " was "+ Order.Status(order.status).label
+ if Logics.order_expires(order): # Order send to expire here
+ debug['expired_orders'].append({idx:context})
+
+ if debug['num_expired_orders'] > 0:
+ self.stdout.write(str(timezone.now()))
+ self.stdout.write(str(debug))
diff --git a/api/management/commands/follow_invoices.py b/api/management/commands/follow_invoices.py
new file mode 100644
index 00000000..52253a2a
--- /dev/null
+++ b/api/management/commands/follow_invoices.py
@@ -0,0 +1,130 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from api.lightning.node import LNNode
+from api.models import LNPayment, Order
+from api.logics import Logics
+
+from django.utils import timezone
+from datetime import timedelta
+from decouple import config
+from base64 import b64decode
+import time
+
+MACAROON = b64decode(config('LND_MACAROON_BASE64'))
+
+class Command(BaseCommand):
+ '''
+ Background: SubscribeInvoices stub iterator would be great to use here.
+ However, it only sends updates when the invoice is OPEN (new) or SETTLED.
+ We are very interested on the other two states (CANCELLED and ACCEPTED).
+ Therefore, this thread (follow_invoices) will iterate over all LNpayment
+ objects and do InvoiceLookupV2 every X seconds to update their state 'live'
+ '''
+
+ help = 'Follows all active hold invoices'
+ rest = 5 # seconds between consecutive checks for invoice updates
+
+ # def add_arguments(self, parser):
+ # parser.add_argument('debug', nargs='+', type=boolean)
+
+ def handle(self, *args, **options):
+ ''' Follows and updates LNpayment objects
+ until settled or canceled'''
+
+ lnd_state_to_lnpayment_status = {
+ 0: LNPayment.Status.INVGEN, # OPEN
+ 1: LNPayment.Status.SETLED, # SETTLED
+ 2: LNPayment.Status.CANCEL, # CANCELLED
+ 3: LNPayment.Status.LOCKED # ACCEPTED
+ }
+
+ stub = LNNode.invoicesstub
+
+ while True:
+ time.sleep(self.rest)
+
+ # time it for debugging
+ t0 = time.time()
+ queryset = LNPayment.objects.filter(type=LNPayment.Types.HOLD, status__in=[LNPayment.Status.INVGEN, LNPayment.Status.LOCKED])
+
+ debug = {}
+ debug['num_active_invoices'] = len(queryset)
+ debug['invoices'] = []
+ at_least_one_changed = False
+
+ for idx, hold_lnpayment in enumerate(queryset):
+ old_status = LNPayment.Status(hold_lnpayment.status).label
+
+ try:
+ request = LNNode.invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(hold_lnpayment.payment_hash))
+ response = stub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())])
+ hold_lnpayment.status = lnd_state_to_lnpayment_status[response.state]
+
+ except Exception as e:
+ # If it fails at finding the invoice it has been canceled.
+ # On RoboSats DB we make a distinction between cancelled and returned (LND does not)
+ if 'unable to locate invoice' in str(e):
+ hold_lnpayment.status = LNPayment.Status.CANCEL
+ # LND restarted.
+ if 'wallet locked, unlock it' in str(e):
+ self.stdout.write(str(timezone.now())+':: Wallet Locked')
+ # Other write to logs
+ else:
+ self.stdout.write(str(e))
+
+ new_status = LNPayment.Status(hold_lnpayment.status).label
+
+ # Only save the hold_payments that change (otherwise this function does not scale)
+ changed = not old_status==new_status
+ if changed:
+ # self.handle_status_change(hold_lnpayment, old_status)
+ hold_lnpayment.save()
+ self.update_order_status(hold_lnpayment)
+
+ # Report for debugging
+ new_status = LNPayment.Status(hold_lnpayment.status).label
+ debug['invoices'].append({idx:{
+ 'payment_hash': str(hold_lnpayment.payment_hash),
+ 'old_status': old_status,
+ 'new_status': new_status,
+ }})
+
+ at_least_one_changed = at_least_one_changed or changed
+
+ debug['time']=time.time()-t0
+
+ if at_least_one_changed:
+ self.stdout.write(str(timezone.now()))
+ self.stdout.write(str(debug))
+
+
+ def update_order_status(self, lnpayment):
+ ''' Background process following LND hold invoices
+ can catch LNpayments changing status. If they do,
+ the order status might have to change too.'''
+
+ # If the LNPayment goes to LOCKED (ACCEPTED)
+ if lnpayment.status == LNPayment.Status.LOCKED:
+ try:
+ # It is a maker bond => Publish order.
+ if hasattr(lnpayment, 'order_made' ):
+ Logics.publish_order(lnpayment.order_made)
+ return
+
+ # It is a taker bond => close contract.
+ elif hasattr(lnpayment, 'order_taken' ):
+ if lnpayment.order_taken.status == Order.Status.TAK:
+ Logics.finalize_contract(lnpayment.order_taken)
+ return
+
+ # It is a trade escrow => move foward order status.
+ elif hasattr(lnpayment, 'order_escrow' ):
+ Logics.trade_escrow_received(lnpayment.order_escrow)
+ return
+ except Exception as e:
+ self.stdout.write(str(e))
+
+ # TODO If a lnpayment goes from LOCKED to INVGED. Totally weird
+ # halt the order
+ if lnpayment.status == LNPayment.Status.LOCKED:
+ pass
\ No newline at end of file
diff --git a/api/models.py b/api/models.py
index bc7edd9f..673e25d8 100644
--- a/api/models.py
+++ b/api/models.py
@@ -2,13 +2,13 @@ from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MaxValueValidator, MinValueValidator, validate_comma_separated_integer_list
from django.db.models.signals import post_save, pre_delete
+from django.template.defaultfilters import truncatechars
from django.dispatch import receiver
from django.utils.html import mark_safe
import uuid
from decouple import config
from pathlib import Path
-from .utils import get_exchange_rate
import json
MIN_TRADE = int(config('MIN_TRADE'))
@@ -16,6 +16,24 @@ MAX_TRADE = int(config('MAX_TRADE'))
FEE = float(config('FEE'))
BOND_SIZE = float(config('BOND_SIZE'))
+
+class Currency(models.Model):
+
+ currency_dict = json.load(open('./frontend/static/assets/currencies.json'))
+ currency_choices = [(int(val), label) for val, label in list(currency_dict.items())]
+
+ currency = models.PositiveSmallIntegerField(choices=currency_choices, null=False, unique=True)
+ exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=None, null=True, validators=[MinValueValidator(0)])
+ timestamp = models.DateTimeField(auto_now_add=True)
+
+ def __str__(self):
+ # returns currency label ( 3 letters code)
+ return self.currency_dict[str(self.currency)]
+
+ class Meta:
+ verbose_name = 'Cached market currency'
+ verbose_name_plural = 'Currencies'
+
class LNPayment(models.Model):
class Types(models.IntegerChoices):
@@ -33,23 +51,23 @@ class LNPayment(models.Model):
LOCKED = 1, 'Locked'
SETLED = 2, 'Settled'
RETNED = 3, 'Returned'
- EXPIRE = 4, 'Expired'
- VALIDI = 5, 'Valid'
- FLIGHT = 6, 'In flight'
- SUCCED = 7, 'Succeeded'
- FAILRO = 8, 'Routing failed'
+ CANCEL = 4, 'Cancelled'
+ EXPIRE = 5, 'Expired'
+ VALIDI = 6, 'Valid'
+ FLIGHT = 7, 'In flight'
+ SUCCED = 8, 'Succeeded'
+ FAILRO = 9, 'Routing failed'
# payment use details
- id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.HOLD)
concept = models.PositiveSmallIntegerField(choices=Concepts.choices, null=False, default=Concepts.MAKEBOND)
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.INVGEN)
routing_retries = models.PositiveSmallIntegerField(null=False, default=0)
# payment info
+ payment_hash = models.CharField(max_length=100, unique=True, default=None, blank=True, primary_key=True)
invoice = models.CharField(max_length=1200, unique=True, null=True, default=None, blank=True) # Some invoices with lots of routing hints might be long
- payment_hash = models.CharField(max_length=100, unique=True, null=True, default=None, blank=True)
preimage = models.CharField(max_length=64, unique=True, null=True, default=None, blank=True)
description = models.CharField(max_length=500, unique=False, null=True, default=None, blank=True)
num_satoshis = models.PositiveBigIntegerField(validators=[MinValueValidator(MIN_TRADE*BOND_SIZE), MaxValueValidator(MAX_TRADE*(1+BOND_SIZE+FEE))])
@@ -61,7 +79,18 @@ class LNPayment(models.Model):
receiver = models.ForeignKey(User, related_name='receiver', on_delete=models.CASCADE, null=True, default=None)
def __str__(self):
- return (f'LN-{str(self.id)[:8]}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}')
+ return (f'LN-{str(self.payment_hash)[:8]}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}')
+
+ class Meta:
+ verbose_name = 'Lightning payment'
+ verbose_name_plural = 'Lightning payments'
+
+ @property
+ def hash(self):
+ # Payment hash is the primary key of LNpayments
+ # However it is too long for the admin panel.
+ # We created a truncated property for display 'hash'
+ return truncatechars(self.payment_hash, 10)
class Order(models.Model):
@@ -86,11 +115,9 @@ class Order(models.Model):
PAY = 13, 'Sending satoshis to buyer'
SUC = 14, 'Sucessful trade'
FAI = 15, 'Failed lightning network routing'
- MLD = 16, 'Maker lost dispute'
- TLD = 17, 'Taker lost dispute'
-
- currency_dict = json.load(open('./frontend/static/assets/currencies.json'))
- currency_choices = [(int(val), label) for val, label in list(currency_dict.items())]
+ WFR = 16, 'Wait for dispute resolution'
+ MLD = 17, 'Maker lost dispute'
+ TLD = 18, 'Taker lost dispute'
# order info
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.WFB)
@@ -99,7 +126,7 @@ class Order(models.Model):
# order details
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False)
- currency = models.PositiveSmallIntegerField(choices=currency_choices, null=False)
+ currency = models.ForeignKey(Currency, null=True, on_delete=models.SET_NULL)
amount = models.DecimalField(max_digits=9, decimal_places=4, validators=[MinValueValidator(0.00001)])
payment_method = models.CharField(max_length=35, null=False, default="not specified", blank=True)
@@ -117,33 +144,59 @@ class Order(models.Model):
maker = models.ForeignKey(User, related_name='maker', on_delete=models.CASCADE, null=True, default=None) # unique = True, a maker can only make one order
taker = models.ForeignKey(User, related_name='taker', on_delete=models.SET_NULL, null=True, default=None, blank=True) # unique = True, a taker can only take one order
is_pending_cancel = models.BooleanField(default=False, null=False) # When collaborative cancel is needed and one partner has cancelled.
- is_disputed = models.BooleanField(default=False, null=False)
is_fiat_sent = models.BooleanField(default=False, null=False)
- # HTLCs
+ # in dispute
+ is_disputed = models.BooleanField(default=False, null=False)
+ maker_statement = models.TextField(max_length=5000, null=True, default=None, blank=True)
+ taker_statement = models.TextField(max_length=5000, null=True, default=None, blank=True)
+
+ # LNpayments
# Order collateral
- maker_bond = models.ForeignKey(LNPayment, related_name='maker_bond', on_delete=models.SET_NULL, null=True, default=None, blank=True)
- taker_bond = models.ForeignKey(LNPayment, related_name='taker_bond', on_delete=models.SET_NULL, null=True, default=None, blank=True)
- trade_escrow = models.ForeignKey(LNPayment, related_name='trade_escrow', on_delete=models.SET_NULL, null=True, default=None, blank=True)
+ maker_bond = models.OneToOneField(LNPayment, related_name='order_made', on_delete=models.SET_NULL, null=True, default=None, blank=True)
+ taker_bond = models.OneToOneField(LNPayment, related_name='order_taken', on_delete=models.SET_NULL, null=True, default=None, blank=True)
+ trade_escrow = models.OneToOneField(LNPayment, related_name='order_escrow', on_delete=models.SET_NULL, null=True, default=None, blank=True)
# 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.OneToOneField(LNPayment, related_name='order_paid', 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)
+ # ratings
+ maker_rated = models.BooleanField(default=False, null=False)
+ taker_rated = models.BooleanField(default=False, null=False)
+
+ t_to_expire = {
+ 0 : int(config('EXP_MAKER_BOND_INVOICE')) , # 'Waiting for maker bond'
+ 1 : 60*60*int(config('PUBLIC_ORDER_DURATION')), # 'Public'
+ 2 : 0, # 'Deleted'
+ 3 : int(config('EXP_TAKER_BOND_INVOICE')), # 'Waiting for taker bond'
+ 4 : 0, # 'Cancelled'
+ 5 : 0, # 'Expired'
+ 6 : 60*int(config('INVOICE_AND_ESCROW_DURATION')), # 'Waiting for trade collateral and buyer invoice'
+ 7 : 60*int(config('INVOICE_AND_ESCROW_DURATION')), # 'Waiting only for seller trade collateral'
+ 8 : 60*int(config('INVOICE_AND_ESCROW_DURATION')), # 'Waiting only for buyer invoice'
+ 9 : 60*60*int(config('FIAT_EXCHANGE_DURATION')), # 'Sending fiat - In chatroom'
+ 10 : 60*60*int(config('FIAT_EXCHANGE_DURATION')), # 'Fiat sent - In chatroom'
+ 11 : 10*24*60*60, # 'In dispute'
+ 12 : 0, # 'Collaboratively cancelled'
+ 13 : 24*60*60, # 'Sending satoshis to buyer'
+ 14 : 24*60*60, # 'Sucessful trade'
+ 15 : 24*60*60, # 'Failed lightning network routing'
+ 16 : 24*60*60, # 'Wait for dispute resolution'
+ 17 : 24*60*60, # 'Maker lost dispute'
+ 18 : 24*60*60, # 'Taker lost dispute'
+ }
def __str__(self):
- # Make relational back to ORDER
- return (f'Order {self.id}: {self.Types(self.type).label} BTC for {float(self.amount)} {self.currency_dict[str(self.currency)]}')
+ return (f'Order {self.id}: {self.Types(self.type).label} BTC for {float(self.amount)} {self.currency}')
+
@receiver(pre_delete, sender=Order)
-def delete_HTLCs_at_order_deletion(sender, instance, **kwargs):
+def delete_lnpayment_at_order_deletion(sender, instance, **kwargs):
to_delete = (instance.maker_bond, instance.buyer_invoice, instance.taker_bond, instance.trade_escrow)
- for htlc in to_delete:
+ for lnpayment in to_delete:
try:
- htlc.delete()
+ lnpayment.delete()
except:
pass
@@ -151,6 +204,9 @@ class Profile(models.Model):
user = models.OneToOneField(User,on_delete=models.CASCADE)
+ # Total trades
+ total_contracts = models.PositiveIntegerField(null=False, default=0)
+
# Ratings stored as a comma separated integer list
total_ratings = models.PositiveIntegerField(null=False, default=0)
latest_ratings = models.CharField(max_length=999, null=True, default=None, validators=[validate_comma_separated_integer_list], blank=True) # Will only store latest ratings
@@ -159,6 +215,8 @@ class Profile(models.Model):
# Disputes
num_disputes = models.PositiveIntegerField(null=False, default=0)
lost_disputes = models.PositiveIntegerField(null=False, default=0)
+ num_disputes_started = models.PositiveIntegerField(null=False, default=0)
+ orders_disputes_started = models.CharField(max_length=999, null=True, default=None, validators=[validate_comma_separated_integer_list], blank=True) # Will only store ID of orders
# RoboHash
avatar = models.ImageField(default="static/assets/misc/unknown_avatar.png", verbose_name='Avatar', blank=True)
@@ -177,8 +235,11 @@ class Profile(models.Model):
@receiver(pre_delete, sender=User)
def del_avatar_from_disk(sender, instance, **kwargs):
- avatar_file=Path('frontend/' + instance.profile.avatar.url)
- avatar_file.unlink() # FIX deleting user fails if avatar is not found
+ try:
+ avatar_file=Path('frontend/' + instance.profile.avatar.url)
+ avatar_file.unlink()
+ except:
+ pass
def __str__(self):
return self.user.username
@@ -209,7 +270,7 @@ class MarketTick(models.Model):
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)
+ currency = models.ForeignKey(Currency, null=True, on_delete=models.SET_NULL)
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
@@ -226,7 +287,8 @@ class MarketTick(models.Model):
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)
+ market_exchange_rate = float(order.currency.exchange_rate)
+ premium = 100 * (price / market_exchange_rate - 1)
tick = MarketTick.objects.create(
price=price,
@@ -239,4 +301,7 @@ class MarketTick(models.Model):
def __str__(self):
return f'Tick: {str(self.id)[:8]}'
+ class Meta:
+ verbose_name = 'Market tick'
+ verbose_name_plural = 'Market ticks'
diff --git a/api/nick_generator/dicts/en/nouns.py b/api/nick_generator/dicts/en/nouns.py
index e48a739d..ae3243e8 100755
--- a/api/nick_generator/dicts/en/nouns.py
+++ b/api/nick_generator/dicts/en/nouns.py
@@ -3633,7 +3633,7 @@ nouns = [
"Fever",
"Few",
"Fiance",
- "Fiancรฉ",
+ "Fiance",
"Fiasco",
"Fiat",
"Fiber",
diff --git a/api/nick_generator/nick_generator.py b/api/nick_generator/nick_generator.py
index 29e5e84a..93371619 100755
--- a/api/nick_generator/nick_generator.py
+++ b/api/nick_generator/nick_generator.py
@@ -41,13 +41,14 @@ class NickGenerator:
else:
raise ValueError("Language not implemented.")
- print(
- f"{lang} SHA256 Nick Generator initialized with:"
- + f"\nUp to {len(adverbs)} adverbs."
- + f"\nUp to {len(adjectives)} adjectives."
- + f"\nUp to {len(nouns)} nouns."
- + f"\nUp to {max_num+1} numerics.\n"
- )
+ if verbose:
+ print(
+ f"{lang} SHA256 Nick Generator initialized with:"
+ + f"\nUp to {len(adverbs)} adverbs."
+ + f"\nUp to {len(adjectives)} adjectives."
+ + f"\nUp to {len(nouns)} nouns."
+ + f"\nUp to {max_num+1} numerics.\n"
+ )
self.use_adv = use_adv
self.use_adj = use_adj
diff --git a/api/serializers.py b/api/serializers.py
index 6beff335..88997f3d 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -13,5 +13,6 @@ class MakeOrderSerializer(serializers.ModelSerializer):
class UpdateOrderSerializer(serializers.Serializer):
invoice = serializers.CharField(max_length=2000, allow_null=True, allow_blank=True, default=None)
- action = serializers.ChoiceField(choices=('take','update_invoice','dispute','cancel','confirm','rate'), allow_null=False)
+ statement = serializers.CharField(max_length=10000, allow_null=True, allow_blank=True, default=None)
+ action = serializers.ChoiceField(choices=('take','update_invoice','submit_statement','dispute','cancel','confirm','rate'), allow_null=False)
rating = serializers.ChoiceField(choices=('1','2','3','4','5'), allow_null=True, allow_blank=True, default=None)
\ No newline at end of file
diff --git a/api/tasks.py b/api/tasks.py
new file mode 100644
index 00000000..82da436a
--- /dev/null
+++ b/api/tasks.py
@@ -0,0 +1,109 @@
+from celery import shared_task
+
+@shared_task(name="users_cleansing")
+def users_cleansing():
+ '''
+ Deletes users never used 12 hours after creation
+ '''
+ from django.contrib.auth.models import User
+ from django.db.models import Q
+ from .logics import Logics
+ from datetime import timedelta
+ from django.utils import timezone
+
+ # Users who's last login has not been in the last 6 hours
+ active_time_range = (timezone.now() - timedelta(hours=6), timezone.now())
+ queryset = User.objects.filter(~Q(last_login__range=active_time_range))
+ queryset = queryset.filter(is_staff=False) # Do not delete staff users
+
+ # And do not have an active trade or any past contract.
+ deleted_users = []
+ for user in queryset:
+ if not user.profile.total_contracts == 0:
+ continue
+ valid, _ = Logics.validate_already_maker_or_taker(user)
+ if valid:
+ deleted_users.append(str(user))
+ user.delete()
+
+ results = {
+ 'num_deleted': len(deleted_users),
+ 'deleted_users': deleted_users,
+ }
+ return results
+
+@shared_task(name='follow_send_payment')
+def follow_send_payment(lnpayment):
+ '''Sends sats to buyer, continuous update'''
+
+ from decouple import config
+ from base64 import b64decode
+
+ from api.lightning.node import LNNode
+ from api.models import LNPayment, Order
+
+ MACAROON = b64decode(config('LND_MACAROON_BASE64'))
+
+ fee_limit_sat = max(lnpayment.num_satoshis * 0.0002, 10) # 200 ppm or 10 sats max
+ request = LNNode.routerrpc.SendPaymentRequest(
+ payment_request=lnpayment.invoice,
+ fee_limit_sat=fee_limit_sat,
+ timeout_seconds=60) # time out payment in 60 seconds
+
+ order = lnpayment.order_paid
+ for response in LNNode.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]):
+ if response.status == 0 : # Status 0 'UNKNOWN'
+ # Not sure when this status happens
+ pass
+
+ if response.status == 1 : # Status 1 'IN_FLIGHT'
+ print('IN_FLIGHT')
+ lnpayment.status = LNPayment.Status.FLIGHT
+ lnpayment.save()
+ order.status = Order.Status.PAY
+ order.save()
+
+ if response.status == 3 : # Status 3 'FAILED'
+ print('FAILED')
+ lnpayment.status = LNPayment.Status.FAILRO
+ lnpayment.save()
+ order.status = Order.Status.FAI
+ order.save()
+ context = LNNode.payment_failure_context[response.failure_reason]
+ # Call for a retry here
+ return False, context
+
+ if response.status == 2 : # Status 2 'SUCCEEDED'
+ print('SUCCEEDED')
+ lnpayment.status = LNPayment.Status.SUCCED
+ lnpayment.save()
+ order.status = Order.Status.SUC
+ order.save()
+ return True, None
+
+@shared_task(name="cache_external_market_prices", ignore_result=True)
+def cache_market():
+
+ from .models import Currency
+ from .utils import get_exchange_rates
+
+ from django.utils import timezone
+
+ exchange_rates = get_exchange_rates(list(Currency.currency_dict.values()))
+ results = {}
+ for val in Currency.currency_dict:
+ rate = exchange_rates[int(val)-1] # currecies are indexed starting at 1 (USD)
+ results[val] = {Currency.currency_dict[val], rate}
+ if str(rate) == 'nan': continue # Do not update if no new rate was found
+
+ # Create / Update database cached prices
+ Currency.objects.update_or_create(
+ id = int(val),
+ currency = int(val),
+ # if there is a Cached market prices matching that id, it updates it with defaults below
+ defaults = {
+ 'exchange_rate': float(rate),
+ 'timestamp': timezone.now(),
+ })
+
+ return results
\ No newline at end of file
diff --git a/api/utils.py b/api/utils.py
index 6c23d924..e138d322 100644
--- a/api/utils.py
+++ b/api/utils.py
@@ -1,21 +1,56 @@
import requests, ring, os
from decouple import config
+import numpy as np
+
+from api.models import Order
market_cache = {}
-@ring.dict(market_cache, 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)
+@ring.dict(market_cache, expire=3) #keeps in cache for 3 seconds
+def get_exchange_rates(currencies):
+ '''
+ Params: list of currency codes.
+ Checks for exchange rates in several public APIs.
+ Returns the median price list.
+ '''
- market_prices = requests.get(config('MARKET_PRICE_API')).json()
- exchange_rate = float(market_prices[currency]['last'])
+ APIS = config('MARKET_PRICE_APIS', cast=lambda v: [s.strip() for s in v.split(',')])
- return exchange_rate
+ api_rates = []
+ for api_url in APIS:
+ try: # If one API is unavailable pass
+ if 'blockchain.info' in api_url:
+ blockchain_prices = requests.get(api_url).json()
+ blockchain_rates = []
+ for currency in currencies:
+ try: # If a currency is missing place a None
+ blockchain_rates.append(float(blockchain_prices[currency]['last']))
+ except:
+ blockchain_rates.append(np.nan)
+ api_rates.append(blockchain_rates)
+
+ elif 'yadio.io' in api_url:
+ yadio_prices = requests.get(api_url).json()
+ yadio_rates = []
+ for currency in currencies:
+ try:
+ yadio_rates.append(float(yadio_prices['BTC'][currency]))
+ except:
+ yadio_rates.append(np.nan)
+ api_rates.append(yadio_rates)
+ except:
+ pass
+
+ if len(api_rates) == 0:
+ return None # Wops there is not API available!
+
+ exchange_rates = np.array(api_rates)
+ median_rates = np.nanmedian(exchange_rates, axis=0)
+
+ return median_rates.tolist()
lnd_v_cache = {}
-
@ring.dict(lnd_v_cache, expire=3600) #keeps in cache for 3600 seconds
def get_lnd_version():
@@ -25,7 +60,6 @@ def get_lnd_version():
return lnd_version
robosats_commit_cache = {}
-
@ring.dict(robosats_commit_cache, expire=3600)
def get_commit_robosats():
@@ -33,4 +67,22 @@ def get_commit_robosats():
lnd_version = stream.read()
return lnd_version
+
+premium_percentile = {}
+@ring.dict(premium_percentile, expire=300)
+def compute_premium_percentile(order):
+
+ queryset = Order.objects.filter(currency=order.currency, status=Order.Status.PUB)
+
+ print(len(queryset))
+ if len(queryset) <= 1:
+ return 0.5
+
+ order_rate = float(order.last_satoshis) / float(order.amount)
+ rates = []
+ for similar_order in queryset:
+ rates.append(float(similar_order.last_satoshis) / float(similar_order.amount))
+
+ rates = np.array(rates)
+ return round(np.sum(rates < order_rate) / len(rates),2)
diff --git a/api/views.py b/api/views.py
index 46dbd2d3..6e529a40 100644
--- a/api/views.py
+++ b/api/views.py
@@ -9,9 +9,9 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from .serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer
-from .models import LNPayment, MarketTick, Order
+from .models import LNPayment, MarketTick, Order, Currency
from .logics import Logics
-from .utils import get_lnd_version, get_commit_robosats
+from .utils import get_lnd_version, get_commit_robosats, compute_premium_percentile
from .nick_generator.nick_generator import NickGenerator
from robohash import Robohash
@@ -54,7 +54,7 @@ class MakerView(CreateAPIView):
# Creates a new order
order = Order(
type=type,
- currency=currency,
+ currency=Currency.objects.get(id=currency),
amount=amount,
payment_method=payment_method,
premium=premium,
@@ -95,10 +95,6 @@ class OrderView(viewsets.ViewSet):
# This is our order.
order = order[0]
- # 1) If order has expired
- if order.status == Order.Status.EXP:
- return Response({'bad_request':'This order has expired'},status.HTTP_400_BAD_REQUEST)
-
# 2) If order has been cancelled
if order.status == Order.Status.UCA:
return Response({'bad_request':'This order has been cancelled by the maker'},status.HTTP_400_BAD_REQUEST)
@@ -106,6 +102,7 @@ class OrderView(viewsets.ViewSet):
return Response({'bad_request':'This order has been cancelled collaborativelly'},status.HTTP_400_BAD_REQUEST)
data = ListOrderSerializer(order).data
+ data['total_secs_exp'] = Order.t_to_expire[order.status]
# if user is under a limit (penalty), inform him.
is_penalized, time_out = Logics.is_penalized(request.user)
@@ -116,17 +113,26 @@ class OrderView(viewsets.ViewSet):
data['is_maker'] = order.maker == request.user
data['is_taker'] = order.taker == request.user
data['is_participant'] = data['is_maker'] or data['is_taker']
- data['ur_nick'] = request.user.username
- # 3) If not a participant and order is not public, forbid.
+ # 3.a) If not a participant and order is not public, forbid.
if not data['is_participant'] and order.status != Order.Status.PUB:
return Response({'bad_request':'You are not allowed to see this order'},status.HTTP_403_FORBIDDEN)
+ # 3.b If order is between public and WF2
+ if order.status >= Order.Status.PUB and order.status < Order.Status.WF2:
+ data['price_now'], data['premium_now'] = Logics.price_and_premium_now(order)
+
+ # 3. c) If maker and Public, add num robots in book, premium percentile and num similar orders.
+ if data['is_maker'] and order.status == Order.Status.PUB:
+ data['robots_in_book'] = None # TODO
+ data['premium_percentile'] = compute_premium_percentile(order)
+ data['num_similar_orders'] = len(Order.objects.filter(currency=order.currency, status=Order.Status.PUB))
+
# 4) Non participants can view details (but only if PUB)
elif not data['is_participant'] and order.status != Order.Status.PUB:
return Response(data, status=status.HTTP_200_OK)
- # For participants add positions, nicks and status as a message
+ # For participants add positions, nicks and status as a message and hold invoices status
data['is_buyer'] = Logics.is_buyer(order,request.user)
data['is_seller'] = Logics.is_seller(order,request.user)
data['maker_nick'] = str(order.maker)
@@ -134,6 +140,26 @@ class OrderView(viewsets.ViewSet):
data['status_message'] = Order.Status(order.status).label
data['is_fiat_sent'] = order.is_fiat_sent
data['is_disputed'] = order.is_disputed
+ data['ur_nick'] = request.user.username
+
+ # Add whether hold invoices are LOCKED (ACCEPTED)
+ # Is there a maker bond? If so, True if locked, False otherwise
+ if order.maker_bond:
+ data['maker_locked'] = order.maker_bond.status == LNPayment.Status.LOCKED
+ else:
+ data['maker_locked'] = False
+
+ # Is there a taker bond? If so, True if locked, False otherwise
+ if order.taker_bond:
+ data['taker_locked'] = order.taker_bond.status == LNPayment.Status.LOCKED
+ else:
+ data['taker_locked'] = False
+
+ # Is there an escrow? If so, True if locked, False otherwise
+ if order.trade_escrow:
+ data['escrow_locked'] = order.trade_escrow.status == LNPayment.Status.LOCKED
+ else:
+ data['escrow_locked'] = False
# If both bonds are locked, participants can see the final trade amount in sats.
if order.taker_bond:
@@ -196,7 +222,7 @@ class OrderView(viewsets.ViewSet):
def take_update_confirm_dispute_cancel(self, request, format=None):
'''
- Here takes place all of updatesto the order object.
+ Here takes place all of the updates to the order object.
That is: take, confim, cancel, dispute, update_invoice or rate.
'''
order_id = request.GET.get(self.lookup_url_kwarg)
@@ -206,20 +232,24 @@ class OrderView(viewsets.ViewSet):
order = Order.objects.get(id=order_id)
- # action is either 1)'take', 2)'confirm', 3)'cancel', 4)'dispute' , 5)'update_invoice' 6)'rate' (counterparty)
+ # action is either 1)'take', 2)'confirm', 3)'cancel', 4)'dispute' , 5)'update_invoice'
+ # 6)'submit_statement' (in dispute), 7)'rate' (counterparty)
action = serializer.data.get('action')
invoice = serializer.data.get('invoice')
+ statement = serializer.data.get('statement')
rating = serializer.data.get('rating')
+
# 1) If action is take, it is a taker request!
if action == 'take':
if order.status == Order.Status.PUB:
valid, context = Logics.validate_already_maker_or_taker(request.user)
if not valid: return Response(context, status=status.HTTP_409_CONFLICT)
-
valid, context = Logics.take(order, request.user)
if not valid: return Response(context, status=status.HTTP_403_FORBIDDEN)
+ return self.get(request)
+
else: Response({'bad_request':'This order is not public anymore.'}, status.HTTP_400_BAD_REQUEST)
# Any other action is only allowed if the user is a participant
@@ -243,7 +273,11 @@ class OrderView(viewsets.ViewSet):
# 5) If action is dispute
elif action == 'dispute':
- valid, context = Logics.open_dispute(order,request.user, rating)
+ valid, context = Logics.open_dispute(order,request.user)
+ if not valid: return Response(context, status.HTTP_400_BAD_REQUEST)
+
+ elif action == 'submit_statement':
+ valid, context = Logics.dispute_statement(order,request.user, statement)
if not valid: return Response(context, status.HTTP_400_BAD_REQUEST)
# 6) If action is rate
@@ -281,14 +315,20 @@ class UserView(APIView):
Response with Avatar and Nickname.
'''
- # if request.user.id:
- # context = {}
- # context['nickname'] = request.user.username
- # participant = not Logics.validate_already_maker_or_taker(request.user)
- # context['bad_request'] = f'You are already logged in as {request.user}'
- # if participant:
- # context['bad_request'] = f'You are already logged in as as {request.user} and have an active order'
- # return Response(context,status.HTTP_200_OK)
+ # If an existing user opens the main page by mistake, we do not want it to create a new nickname/profile for him
+ if request.user.is_authenticated:
+ context = {'nickname': request.user.username}
+ not_participant, _ = Logics.validate_already_maker_or_taker(request.user)
+
+ # Does not allow this 'mistake' if an active order
+ if not not_participant:
+ context['bad_request'] = f'You are already logged in as {request.user} and have an active order'
+ return Response(context, status.HTTP_400_BAD_REQUEST)
+
+ # Does not allow this 'mistake' if the last login was sometime ago (5 minutes)
+ # if request.user.last_login < timezone.now() - timedelta(minutes=5):
+ # context['bad_request'] = f'You are already logged in as {request.user}'
+ # return Response(context, status.HTTP_400_BAD_REQUEST)
token = request.GET.get(self.lookup_url_kwarg)
@@ -304,10 +344,10 @@ class UserView(APIView):
context['bad_request'] = 'The token does not have enough entropy'
return Response(context, status=status.HTTP_400_BAD_REQUEST)
- # Hashes the token, only 1 iteration. Maybe more is better.
+ # Hash the token, only 1 iteration.
hash = hashlib.sha256(str.encode(token)).hexdigest()
- # Generate nickname
+ # Generate nickname deterministically
nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0]
context['nickname'] = nickname
@@ -316,13 +356,12 @@ class UserView(APIView):
rh.assemble(roboset='set1', bgset='any')# for backgrounds ON
# Does not replace image if existing (avoid re-avatar in case of nick collusion)
-
image_path = avatar_path.joinpath(nickname+".png")
if not image_path.exists():
with open(image_path, "wb") as f:
rh.img.save(f, format="png")
- # Create new credentials and log in if nickname is new
+ # Create new credentials and login if nickname is new
if len(User.objects.filter(username=nickname)) == 0:
User.objects.create_user(username=nickname, password=token, is_staff=False)
user = authenticate(request, username=nickname, password=token)
@@ -410,26 +449,26 @@ class InfoView(ListAPIView):
context['num_public_sell_orders'] = len(Order.objects.filter(type=Order.Types.SELL, status=Order.Status.PUB))
# Number of active users (logged in in last 30 minutes)
- active_user_time_range = (timezone.now() - timedelta(minutes=30), timezone.now())
- context['num_active_robotsats'] = len(User.objects.filter(last_login__range=active_user_time_range))
+ today = datetime.today()
+ context['active_robots_today'] = len(User.objects.filter(last_login__day=today.day))
# Compute average premium and volume of today
- today = datetime.today()
-
queryset = MarketTick.objects.filter(timestamp__day=today.day)
if not len(queryset) == 0:
- premiums = []
+ weighted_premiums = []
volumes = []
for tick in queryset:
- premiums.append(tick.premium)
+ weighted_premiums.append(tick.premium*tick.volume)
volumes.append(tick.volume)
- avg_premium = sum(premiums) / len(premiums)
+
total_volume = sum(volumes)
+ # Avg_premium is the weighted average of the premiums by volume
+ avg_premium = sum(weighted_premiums) / total_volume
else:
- avg_premium = None
- total_volume = None
+ avg_premium = 0
+ total_volume = 0
- context['today_avg_nonkyc_btc_premium'] = avg_premium
+ context['today_avg_nonkyc_btc_premium'] = round(avg_premium,2)
context['today_total_volume'] = total_volume
context['lnd_version'] = get_lnd_version()
context['robosats_running_commit_hash'] = get_commit_robosats()
diff --git a/chat/consumers.py b/chat/consumers.py
index 2013b34a..cb3da612 100644
--- a/chat/consumers.py
+++ b/chat/consumers.py
@@ -21,10 +21,7 @@ class ChatRoomConsumer(AsyncWebsocketConsumer):
# if not (Logics.is_buyer(order[0], self.user) or Logics.is_seller(order[0], self.user)):
# print ("Outta this chat")
# return False
-
- print(self.user_nick)
- print(self.order_id)
-
+
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
@@ -56,8 +53,16 @@ class ChatRoomConsumer(AsyncWebsocketConsumer):
message = event['message']
nick = event['nick']
+ # Insert a white space in words longer than 22 characters.
+ # Helps when messages overflow in a single line.
+ words = message.split(' ')
+ fix_message = ''
+ for word in words:
+ word = ' '.join(word[i:i+22] for i in range(0, len(word), 22))
+ fix_message = fix_message +' '+ word
+
await self.send(text_data=json.dumps({
- 'message': message,
+ 'message': fix_message,
'user_nick': nick,
}))
diff --git a/chat/templates/chatroom.html b/chat/templates/chatroom.html
deleted file mode 100644
index f638de64..00000000
--- a/chat/templates/chatroom.html
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-