Add expiration logics. Add dispute statements.

This commit is contained in:
Reckless_Satoshi 2022-01-16 13:54:42 -08:00
parent 9009f35269
commit 9d883ccc4d
No known key found for this signature in database
GPG Key ID: 9C4585B561315571
7 changed files with 253 additions and 52 deletions

View File

@ -97,21 +97,104 @@ class Logics():
@classmethod
def order_expires(cls, order):
''' General case when time runs out. Only
used when the maker does not lock a publishing bond'''
''' General cases when time runs out.'''
if order.status == Order.Status.WFB:
# 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]
if order.status in do_nothing:
return False
elif order.status == Order.Status.WFB:
order.status = Order.Status.EXP
order.maker = None
order.taker = None
order.save()
return True
if order.status == Order.Status.PUB:
elif order.status == Order.Status.PUB:
cls.return_bond(order.maker_bond)
order.status = Order.Status.EXP
order.maker = None
order.taker = None
order.save()
return True
elif order.status == Order.Status.TAK:
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)
order.status = Order.Status.EXP
order.maker = None
order.taker = None
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)
order.status = Order.Status.EXP
order.maker = None
order.taker = None
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)
order.status = Order.Status.PUB
order.taker = None
order.taker_bond = 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 most likely AFK since he did not submit an invoice; will most
# likely 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)
order.status = Order.Status.EXP
order.maker = None
order.taker = None
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)
order.status = Order.Status.PUB
order.taker = None
order.taker_bond = 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.
cls.open_dispute(order)
return True
def kick_taker(order):
''' The taker did not lock the taker_bond. Now he has to go'''
@ -125,10 +208,48 @@ class Logics():
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!
order.expires_at = order.created_at + timedelta(seconds=Order.t_to_expire[Order.Status.PUB])
order.save()
return True
@classmethod
def open_dispute(cls, order, user=None):
# Always settle the escrow during a dispute (same as with 'Fiat Sent')
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.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 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 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() + Order.t_to_expire[Order.Status.WFR]
order.save()
return True, None
@classmethod
def buyer_invoice_amount(cls, order, user):
''' Computes buyer invoice amount. Uses order.last_satoshis,
@ -234,7 +355,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):
if cls.settle_bond(order.maker_bond):
order.maker = None
order.status = Order.Status.UCA
order.save()
@ -257,7 +378,7 @@ 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
@ -268,7 +389,7 @@ class Logics():
'''The order into cancelled status if maker cancels.'''
elif order.status > Order.Status.TAK and order.status < Order.Status.CHA 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
@ -277,7 +398,7 @@ class Logics():
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.'''
@ -383,7 +504,7 @@ 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 {str(float(order.amount)) + str(order.currency)}"# Order.currency_dict[str(order.currency)]}"
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
@ -472,22 +593,20 @@ 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 return_bond(bond):
'''returns a bond'''
if LNNode.cancel_return_hold_invoice(bond.payment_hash):

View File

@ -107,8 +107,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'
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)
@ -135,10 +136,14 @@ 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, unique=True, null=True, default=None, blank=True)
taker_statement = models.TextField(max_length=5000, unique=True, 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)
@ -147,11 +152,11 @@ class Order(models.Model):
# buyer payment LN invoice
buyer_invoice = models.ForeignKey(LNPayment, related_name='buyer_invoice', on_delete=models.SET_NULL, null=True, default=None, blank=True)
# cancel LN invoice // these are only needed to charge lower-than-bond amounts. E.g., a taken order has a small cost if cancelled, to avoid DDOSing.
maker_cancel = models.ForeignKey(LNPayment, related_name='maker_cancel', on_delete=models.SET_NULL, null=True, default=None, blank=True)
taker_cancel = models.ForeignKey(LNPayment, related_name='taker_cancel', on_delete=models.SET_NULL, null=True, default=None, blank=True)
# 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)
total_time_to_expire = {
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'
@ -163,13 +168,14 @@ class Order(models.Model):
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 : 24*60*60, # 'In dispute'
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, # 'Maker lost dispute'
17 : 24*60*60, # 'Taker lost dispute'
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):
@ -201,6 +207,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)
@ -254,7 +262,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=Currency.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

View File

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

View File

@ -43,8 +43,8 @@ def users_cleansing():
@shared_task(name="orders_expire")
def orders_expire(rest_secs):
'''
Continuously checks order expiration times for 1 hour.
If order is expires, it handles the actions.
Continuously checks order expiration times for 1 hour. If order
has expires, it calls the logics module for expiration handling.
'''
now = timezone.now()
end_time = now + timedelta(hours=1)
@ -55,8 +55,8 @@ def orders_expire(rest_secs):
queryset = queryset.filter(expires_at__lt=now) # expires at lower than now
for order in queryset:
context.append(str(order)+ " was "+ Order.Status(order.status).label)
Logics.order_expires(order)
if Logics.order_expires(order): # Order send to expire here
context.append(str(order)+ " was "+ Order.Status(order.status).label)
# Allow for some thread rest.
time.sleep(rest_secs)
@ -77,12 +77,14 @@ def follow_lnd_payment():
''' Makes a payment and follows it.
Updates the LNpayment object, and retries
until payment is done'''
pass
@shared_task
def follow_lnd_hold_invoice():
''' Follows and updates LNpayment object
until settled or canceled'''
pass
@shared_task(name="cache_external_market_prices", ignore_result=True)

View File

@ -106,7 +106,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.total_time_to_expire[order.status]
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)
@ -217,10 +217,13 @@ 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':
@ -255,7 +258,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

View File

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

View File

@ -38,6 +38,7 @@ export default class TradeBox extends Component {
super(props);
this.state = {
badInvoice: false,
badStatement: false,
}
}
@ -200,8 +201,6 @@ export default class TradeBox extends Component {
});
}
// Fix this. It's clunky because it takes time. this.props.data does not refresh until next refresh of OrderPage.
handleClickSubmitInvoiceButton=()=>{
this.setState({badInvoice:false});
@ -219,10 +218,34 @@ export default class TradeBox extends Component {
& console.log(data));
}
handleInputDisputeChanged=(e)=>{
this.setState({
statement: e.target.value,
badStatement: false,
});
}
handleClickSubmitStatementButton=()=>{
this.setState({badInvoice:false});
const requestOptions = {
method: 'POST',
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
body: JSON.stringify({
'action':'submit_statement',
'statement': this.state.statement,
}),
};
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
.then((response) => response.json())
.then((data) => this.setState({badStatement:data.bad_statement})
& console.log(data));
}
showInputInvoice(){
return (
// TODO Camera option to read QR
// TODO Option to upload files and images
<Grid container spacing={1}>
<Grid item xs={12} align="center">
@ -252,7 +275,51 @@ export default class TradeBox extends Component {
/>
</Grid>
<Grid item xs={12} align="center">
<Button onClick={this.handleClickSubmitInvoiceButton} variant='contained' color='primary'>Submit</Button>
<Button onClick={this.handleClickSubmitStatementButton} variant='contained' color='primary'>Submit</Button>
</Grid>
{this.showBondIsLocked()}
</Grid>
)
}
// Asks the user for a dispute statement.
showInDisputeStatement(){
return (
// TODO Option to upload files
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b> A dispute has been opened </b>
</Typography>
</Grid>
<Grid item xs={12} align="left">
<Typography component="body2" variant="body2">
Please, submit your statement. Be clear and specific about what happened and provide the necessary
evidence. It is best to provide a burner email, XMPP or telegram username to follow up with the staff.
Disputes are solved at the discretion of real robots <i>(aka humans)</i>, so be as helpful
as possible to ensure a fair outcome. Max 5000 chars.
</Typography>
</Grid>
<Grid item xs={12} align="center">
<TextField
error={this.state.badStatement}
helperText={this.state.badStatement ? this.state.badStatement : "" }
label={"Submit dispute statement"}
required
inputProps={{
style: {textAlign:"center"}
}}
multiline
rows={4}
onChange={this.handleInputDisputeChanged}
/>
</Grid>
<Grid item xs={12} align="center">
<Button onClick={this.handleClickSubmitStatementButton} variant='contained' color='primary'>Submit</Button>
</Grid>
{this.showBondIsLocked()}
@ -463,8 +530,8 @@ handleRatingChange=(e)=>{
{/* Trade Finished - Payment Routing Failed */}
{this.props.data.isBuyer & this.props.data.statusCode == 15 ? this.showUpdateInvoice() : ""}
{/* Trade Finished - Payment Routing Failed - TODO Needs more planning */}
{this.props.data.statusCode == 11 ? this.showInDispute() : ""}
{/* Trade Finished - TODO Needs more planning */}
{this.props.data.statusCode == 11 ? this.showInDisputeStatement() : ""}
{/* TODO */}