From 28d18a484215682a6057214762143a28a3d46e66 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Mon, 17 Jan 2022 15:11:41 -0800 Subject: [PATCH] Add background order updates. Add confirm boxes for Dispute and Fiat Received --- api/logics.py | 92 +++++++++++----- api/management/commands/clean_orders.py | 7 +- api/management/commands/follow_invoices.py | 79 +++++++++----- api/models.py | 13 +-- frontend/src/components/OrderPage.js | 2 +- frontend/src/components/TradeBox.js | 118 +++++++++++++++++---- 6 files changed, 231 insertions(+), 80 deletions(-) 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( + + + {"Do you want to open a dispute?"} + + + + The RoboSats staff will examine the statements and evidence provided by the participants. + It is best if you provide a burner contact method on your statement for the staff to contact you. + The satoshis in the trade escrow will be sent to the dispute winner, while the dispute + loser will lose the bond. + + + + + + + + ) + } + + handleClickOpenConfirmFiatReceived = () => { + this.setState({openConfirmFiatReceived: true}); + }; + handleClickCloseConfirmFiatReceived = () => { + this.setState({openConfirmFiatReceived: false}); + }; + + handleClickTotallyConfirmFiatReceived = () =>{ + this.handleClickConfirmButton(); + this.handleClickCloseConfirmFiatReceived(); + }; + + ConfirmFiatReceivedDialog =() =>{ + return( + + + {"Confirm you received " +this.props.data.currencyCode+ "?"} + + + + Confirming that you received the fiat will finalize the trade. The satoshis + in the escrow will be released to the buyer. Only confirm after the {this.props.data.currencyCode+ " "} + has arrived to your account. In addition, if you have received {this.props.data.currencyCode+ " "} + and do not confirm the receipt, you risk losing your bond. + + + + + + + + ) + } + 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