diff --git a/api/logics.py b/api/logics.py
index 547cad90..42523e2b 100644
--- a/api/logics.py
+++ b/api/logics.py
@@ -1,11 +1,14 @@
-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, Currency
+from api.models import Order, LNPayment, MarketTick, User, Currency
from decouple import config
+from api.tasks import follow_send_payment
+
import math
+import ast
FEE = float(config('FEE'))
BOND_SIZE = float(config('BOND_SIZE'))
@@ -140,6 +143,7 @@ class Logics():
cls.settle_bond(order.maker_bond)
cls.settle_bond(order.taker_bond)
+ cls.cancel_escrow(order)
order.status = Order.Status.EXP
order.maker = None
order.taker = None
@@ -152,6 +156,7 @@ class Logics():
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.maker = None
order.taker = None
@@ -161,22 +166,25 @@ class Logics():
# 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.status = Order.Status.PUB
order.taker = None
order.taker_bond = None
+ order.trade_escrow = None
order.expires_at = order.created_at + timedelta(seconds=Order.t_to_expire[Order.Status.PUB])
order.save()
return True
elif order.status == Order.Status.WFI:
# The trade could happen without a buyer invoice. However, this user
- # is likely AFK since he did not submit an invoice; will probably
- # desert the contract as well.
+ # 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.maker = None
order.taker = None
@@ -186,17 +194,19 @@ class Logics():
# 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.status = Order.Status.PUB
order.taker = None
order.taker_bond = None
+ order.trade_escrow = None
order.expires_at = order.created_at + timedelta(seconds=Order.t_to_expire[Order.Status.PUB])
order.save()
return True
elif order.status == Order.Status.CHA:
# Another weird case. The time to confirm 'fiat sent' expired. Yet no dispute
- # was opened. A seller-scammer could persuade a buyer to not click "fiat sent"
- # as of now, we assume this is a dispute case by default.
+ # 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
@@ -219,12 +229,14 @@ class Logics():
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 = order.created_at + timedelta(seconds=Order.t_to_expire[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.
@@ -235,6 +247,7 @@ class Logics():
profile.save()
return True, None
+
def dispute_statement(order, user, statement):
''' Updates the dispute statements in DB'''
if not order.status == Order.Status.DIS:
@@ -319,16 +332,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()
@@ -413,15 +428,20 @@ class Logics():
else:
return False, {'bad_request':'You cannot cancel this order'}
+ def publish_order(order):
+ if order.status == Order.Status.WFB:
+ order.status = Order.Status.PUB
+ # With the bond confirmation the order is extended 'public_order_duration' hours
+ 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):
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 = order.created_at + timedelta(seconds=Order.t_to_expire[Order.Status.PUB])
- order.save()
+ cls.publish_order(order)
return True
return False
@@ -467,13 +487,12 @@ class Logics():
return True, {'bond_invoice':hold_payment['invoice'], 'bond_satoshis':bond_satoshis}
@classmethod
- def is_taker_bond_locked(cls, order):
- if order.taker_bond.status == LNPayment.Status.LOCKED:
- return True
+ def finalize_contract(cls, order):
+ ''' When the taker locks the taker_bond
+ the contract is final '''
- if LNNode.validate_hold_invoice_locked(order.taker_bond.payment_hash):
# 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()
@@ -492,6 +511,14 @@ class Logics():
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
+ if LNNode.validate_hold_invoice_locked(order.taker_bond.payment_hash):
+ cls.finalize_contract(order)
+ return True
return False
@classmethod
@@ -618,11 +645,17 @@ class Logics():
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 bond == None:
return
-
try:
LNNode.cancel_return_hold_invoice(bond.payment_hash)
bond.status = LNPayment.Status.RETNED
@@ -631,10 +664,12 @@ class Logics():
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 accepted
+ # Same as return bond, but used when the invoice was never LOCKED
if bond == None:
return True
try:
@@ -645,11 +680,12 @@ class Logics():
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
- suceeded, context = LNNode.pay_invoice(order.buyer_invoice.invoice, order.buyer_invoice.num_satoshis)
+ suceeded, context = follow_send_payment(order.buyer_invoice)
return suceeded, context
@classmethod
@@ -703,11 +739,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
index 5ea0a71e..033784c5 100644
--- a/api/management/commands/clean_orders.py
+++ b/api/management/commands/clean_orders.py
@@ -36,6 +36,7 @@ class Command(BaseCommand):
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})
-
- self.stdout.write(str(timezone.now()))
- self.stdout.write(str(debug))
+
+ 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
index 1956ba83..e1edb6ec 100644
--- a/api/management/commands/follow_invoices.py
+++ b/api/management/commands/follow_invoices.py
@@ -1,21 +1,25 @@
from django.core.management.base import BaseCommand, CommandError
-from django.utils import timezone
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
-from api.models import LNPayment
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.
+ 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 to update their state 'live' '''
+ objects and do InvoiceLookupV2 every X seconds to update their state 'live'
+ '''
help = 'Follows all active hold invoices'
@@ -27,10 +31,10 @@ class Command(BaseCommand):
until settled or canceled'''
lnd_state_to_lnpayment_status = {
- 0: LNPayment.Status.INVGEN,
- 1: LNPayment.Status.SETLED,
- 2: LNPayment.Status.CANCEL,
- 3: LNPayment.Status.LOCKED
+ 0: LNPayment.Status.INVGEN, # OPEN
+ 1: LNPayment.Status.SETLED, # SETTLED
+ 2: LNPayment.Status.CANCEL, # CANCELLED
+ 3: LNPayment.Status.LOCKED # ACCEPTED
}
stub = LNNode.invoicesstub
@@ -45,6 +49,7 @@ class Command(BaseCommand):
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
@@ -56,29 +61,55 @@ class Command(BaseCommand):
# 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)
- except:
- hold_lnpayment.status = LNPayment.Status.CANCEL
- continue
+ except Exception as e:
+ if 'unable to locate invoice' in str(e):
+ hold_lnpayment.status = LNPayment.Status.CANCEL
+ 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),
- 'status_changed': not old_status==new_status,
- 'old_status': old_status,
- 'new_status': new_status,
- }})
+ # 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,
+ }})
- debug['time']=time.time()-t0
-
- self.stdout.write(str(timezone.now())+str(debug))
+ 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))
-
\ No newline at end of file
+ def update_order_status(self, lnpayment):
+ ''' Background process following LND hold invoices
+ might catch LNpayments changing status. If they do,
+ the order status might have to change status too.'''
+
+ # If the LNPayment goes to LOCKED (ACCEPTED)
+ if lnpayment.status == LNPayment.Status.LOCKED:
+
+ # It is a maker bond => Publish order.
+ order = lnpayment.order_made
+ if not order == None:
+ Logics.publish_order(order)
+ return
+
+ # It is a taker bond => close contract.
+ order = lnpayment.order_taken
+ if not order == None:
+ if order.status == Order.Status.TAK:
+ Logics.finalize_contract(order)
+ return
\ No newline at end of file
diff --git a/api/models.py b/api/models.py
index 8debaf5c..a65515e7 100644
--- a/api/models.py
+++ b/api/models.py
@@ -146,16 +146,16 @@ class Order(models.Model):
# 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)
- # Unused so far. Cancel LN invoices // 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'
@@ -182,6 +182,7 @@ class Order(models.Model):
def __str__(self):
# Make relational back to ORDER
return (f'Order {self.id}: {self.Types(self.type).label} BTC for {float(self.amount)} {self.currency}')
+
@receiver(pre_delete, sender=Order)
def delete_lnpayment_at_order_deletion(sender, instance, **kwargs):
diff --git a/frontend/src/components/OrderPage.js b/frontend/src/components/OrderPage.js
index 453867ed..2b05a63b 100644
--- a/frontend/src/components/OrderPage.js
+++ b/frontend/src/components/OrderPage.js
@@ -61,7 +61,7 @@ export default class OrderPage extends Component {
"8": 10000, //'Waiting only for buyer invoice'
"9": 10000, //'Sending fiat - In chatroom'
"10": 15000, //'Fiat sent - In chatroom'
- "11": 300000, //'In dispute'
+ "11": 60000, //'In dispute'
"12": 9999999,//'Collaboratively cancelled'
"13": 120000, //'Sending satoshis to buyer'
"14": 9999999,//'Sucessful trade'
diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js
index 17b3dc26..e8ed00a8 100644
--- a/frontend/src/components/TradeBox.js
+++ b/frontend/src/components/TradeBox.js
@@ -1,5 +1,5 @@
import React, { Component } from "react";
-import { Link, Paper, Rating, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon} from "@mui/material"
+import { Link, Paper, Rating, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
import QRCode from "react-qr-code";
import Chat from "./Chat"
@@ -37,11 +37,100 @@ export default class TradeBox extends Component {
constructor(props) {
super(props);
this.state = {
+ openConfirmFiatReceived: false,
+ openConfirmDispute: false,
badInvoice: false,
badStatement: false,
}
}
-
+
+ handleClickOpenConfirmDispute = () => {
+ this.setState({openConfirmDispute: true});
+ };
+ handleClickCloseConfirmDispute = () => {
+ this.setState({openConfirmDispute: false});
+ };
+
+ handleClickAgreeDisputeButton=()=>{
+ 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));
+ this.handleClickCloseConfirmDispute();
+ }
+
+ ConfirmDisputeDialog =() =>{
+ return(
+
+ )
+ }
+
+ handleClickOpenConfirmFiatReceived = () => {
+ this.setState({openConfirmFiatReceived: true});
+ };
+ handleClickCloseConfirmFiatReceived = () => {
+ this.setState({openConfirmFiatReceived: false});
+ };
+
+ handleClickTotallyConfirmFiatReceived = () =>{
+ this.handleClickConfirmButton();
+ this.handleClickCloseConfirmFiatReceived();
+ };
+
+ ConfirmFiatReceivedDialog =() =>{
+ return(
+
+ )
+ }
+
showQRInvoice=()=>{
return (
@@ -275,7 +364,7 @@ export default class TradeBox extends Component {
/>
-
+
{this.showBondIsLocked()}
@@ -382,18 +471,7 @@ export default class TradeBox extends Component {
.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));
-}
+
handleRatingChange=(e)=>{
const requestOptions = {
method: 'POST',
@@ -419,11 +497,9 @@ handleRatingChange=(e)=>{
}
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(
-
+
)
}
@@ -432,7 +508,7 @@ handleRatingChange=(e)=>{
// 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(
-
+
)
}
@@ -487,7 +563,7 @@ handleRatingChange=(e)=>{
-
+
)
@@ -497,6 +573,8 @@ handleRatingChange=(e)=>{
render() {
return (
+
+
Contract Box