From 9d883ccc4d3769f6dab61e7299e407d51772e5c4 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Sun, 16 Jan 2022 13:54:42 -0800 Subject: [PATCH] Add expiration logics. Add dispute statements. --- api/logics.py | 163 ++++++++++++++++++++++++---- api/models.py | 32 ++++-- api/serializers.py | 3 +- api/tasks.py | 10 +- api/views.py | 13 ++- chat/consumers.py | 5 +- frontend/src/components/TradeBox.js | 79 +++++++++++++- 7 files changed, 253 insertions(+), 52 deletions(-) diff --git a/api/logics.py b/api/logics.py index e31937f5..dae70ab0 100644 --- a/api/logics.py +++ b/api/logics.py @@ -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): diff --git a/api/models.py b/api/models.py index ee718031..a2d04adc 100644 --- a/api/models.py +++ b/api/models.py @@ -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 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 index 6fe4f75c..16219ac8 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -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) diff --git a/api/views.py b/api/views.py index 3ddbb600..812b635b 100644 --- a/api/views.py +++ b/api/views.py @@ -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 diff --git a/chat/consumers.py b/chat/consumers.py index a65ed459..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 diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index c71f2f08..fb359736 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -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 @@ -252,7 +275,51 @@ export default class TradeBox extends Component { /> - + + + + {this.showBondIsLocked()} + + ) + } + + // Asks the user for a dispute statement. + showInDisputeStatement(){ + return ( + + // TODO Option to upload files + + + + + A dispute has been opened + + + + + 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 (aka humans), so be as helpful + as possible to ensure a fair outcome. Max 5000 chars. + + + + + + + + {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 */}