Add background order updates. Add confirm boxes for Dispute and Fiat Received

This commit is contained in:
Reckless_Satoshi 2022-01-17 15:11:41 -08:00
parent 0db73c7c82
commit 28d18a4842
No known key found for this signature in database
GPG Key ID: 9C4585B561315571
6 changed files with 231 additions and 80 deletions

View File

@ -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.'}

View File

@ -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))

View File

@ -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))
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

View File

@ -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):

View File

@ -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'

View File

@ -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(
<Dialog
open={this.state.openConfirmDispute}
onClose={this.handleClickCloseConfirmDispute}
aria-labelledby="open-dispute-dialog-title"
aria-describedby="open-dispute-dialog-description"
>
<DialogTitle id="open-dispute-dialog-title">
{"Do you want to open a dispute?"}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
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.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleClickCloseConfirmDispute} autoFocus>Disagree</Button>
<Button onClick={this.handleClickAgreeDisputeButton}> Agree </Button>
</DialogActions>
</Dialog>
)
}
handleClickOpenConfirmFiatReceived = () => {
this.setState({openConfirmFiatReceived: true});
};
handleClickCloseConfirmFiatReceived = () => {
this.setState({openConfirmFiatReceived: false});
};
handleClickTotallyConfirmFiatReceived = () =>{
this.handleClickConfirmButton();
this.handleClickCloseConfirmFiatReceived();
};
ConfirmFiatReceivedDialog =() =>{
return(
<Dialog
open={this.state.openConfirmFiatReceived}
onClose={this.handleClickCloseConfirmFiatReceived}
aria-labelledby="fiat-received-dialog-title"
aria-describedby="fiat-received-dialog-description"
>
<DialogTitle id="open-dispute-dialog-title">
{"Confirm you received " +this.props.data.currencyCode+ "?"}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
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.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleClickCloseConfirmFiatReceived} autoFocus>Go back</Button>
<Button onClick={this.handleClickTotallyConfirmFiatReceived}> Confirm </Button>
</DialogActions>
</Dialog>
)
}
showQRInvoice=()=>{
return (
<Grid container spacing={1}>
@ -275,7 +364,7 @@ export default class TradeBox extends Component {
/>
</Grid>
<Grid item xs={12} align="center">
<Button onClick={this.handleClickSubmitStatementButton} variant='contained' color='primary'>Submit</Button>
<Button onClick={this.handleClickSubmitInvoiceButton} variant='contained' color='primary'>Submit</Button>
</Grid>
{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(
<Grid item xs={12} align="center">
<Button defaultValue="confirm" variant='contained' color='secondary' onClick={this.handleClickConfirmButton}>Confirm {this.props.data.currencyCode} received</Button>
<Button defaultValue="confirm" variant='contained' color='secondary' onClick={this.handleClickOpenConfirmFiatReceived}>Confirm {this.props.data.currencyCode} received</Button>
</Grid>
)
}
@ -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(
<Grid item xs={12} align="center">
<Button color="inherit" onClick={this.handleClickOpenDisputeButton}>Open Dispute</Button>
<Button color="inherit" onClick={this.handleClickOpenConfirmDispute}>Open Dispute</Button>
</Grid>
)
}
@ -487,7 +563,7 @@ handleRatingChange=(e)=>{
<Rating name="size-large" defaultValue={2} size="large" onChange={this.handleRatingChange} />
</Grid>
<Grid item xs={12} align="center">
<Button color='primary' to='/' component={Link}>Start Again</Button>
<Button color='primary' href='/' component="a">Start Again</Button>
</Grid>
</Grid>
)
@ -497,6 +573,8 @@ handleRatingChange=(e)=>{
render() {
return (
<Grid container spacing={1} style={{ width:330}}>
<this.ConfirmDisputeDialog/>
<this.ConfirmFiatReceivedDialog/>
<Grid item xs={12} align="center">
<Typography component="h5" variant="h5">
Contract Box