diff --git a/api/lightning/cln.py b/api/lightning/cln.py index 824884178..532c92ea4 100755 --- a/api/lightning/cln.py +++ b/api/lightning/cln.py @@ -1,5 +1,6 @@ import hashlib import os +import random import secrets import struct import time @@ -273,7 +274,7 @@ def gen_hold_invoice( request = hold_pb2.HoldInvoiceRequest( description=description, amount_msat=hold_pb2.Amount(msat=num_satoshis * 1_000), - label=f"Order:{order_id}-{lnpayment_concept}-{time}", + label=f"Order:{order_id}-{lnpayment_concept}-{time}--{random.randint(1, 100000)}", expiry=invoice_expiry, cltv=cltv_expiry_blocks, preimage=preimage, # preimage is actually optional in cln, as cln would generate one by default diff --git a/api/logics.py b/api/logics.py index 5bbccb72c..1d5fa34d8 100644 --- a/api/logics.py +++ b/api/logics.py @@ -7,7 +7,7 @@ from django.utils import timezone from api.lightning.node import LNNode -from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order +from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, TakeOrder from api.tasks import send_devfund_donation, send_notification, nostr_send_order_event from api.utils import get_minning_fee, validate_onchain_address, location_country from chat.models import Message @@ -51,20 +51,33 @@ def validate_already_maker_or_taker(cls, user): Order.Status.WFR, ] """Checks if the user is already partipant of an active order""" - queryset = Order.objects.filter(maker=user, status__in=active_order_status) - if queryset.exists(): + queryset_maker = Order.objects.filter( + maker=user, status__in=active_order_status + ) + if queryset_maker.exists(): return ( False, {"bad_request": "You are already maker of an active order"}, - queryset[0], + queryset_maker[0], ) - queryset = Order.objects.filter(taker=user, status__in=active_order_status) - if queryset.exists(): + queryset_taker = Order.objects.filter( + taker=user, status__in=active_order_status + ) + queryset_pretaker = TakeOrder.objects.filter( + taker=user, expires_at__gt=timezone.now() + ) + if queryset_taker.exists(): return ( False, {"bad_request": "You are already taker of an active order"}, - queryset[0], + queryset_taker[0], + ) + elif queryset_pretaker.exists(): + return ( + False, + {"bad_request": "You are already taking an active order"}, + queryset_pretaker[0].order, ) # Edge case when the user is in an order that is failing payment and he is the buyer @@ -177,16 +190,13 @@ def take(cls, order, user, amount=None): f"You need to wait {time_out} seconds to take an order", } else: - if order.has_range: - order.amount = amount - order.taker = user - order.update_status(Order.Status.TAK) - order.expires_at = timezone.now() + timedelta( - seconds=order.t_to_expire(Order.Status.TAK) + TakeOrder.objects.create( + amount=amount, + taker=user, + order=order, + expires_at=timezone.now() + + timedelta(seconds=order.t_to_expire(Order.Status.TAK)), ) - order.save(update_fields=["amount", "taker", "expires_at"]) - - nostr_send_order_event.delay(order_id=order.id) order.log( f"Taken by Robot({user.robot.id},{user.username}) for {order.amount} fiat units" @@ -244,6 +254,14 @@ def price_and_premium_now(order): return price, premium + @classmethod + def take_order_expires(cls, take_order): + if take_order.expires_at < timezone.now(): + cls.cancel_bond(take_order.taker_bond) + return True + else: + return False + @classmethod def order_expires(cls, order): """General cases when time runs out.""" @@ -284,7 +302,13 @@ def order_expires(cls, order): cls.return_bond(order.maker_bond) order.update_status(Order.Status.EXP) order.expiry_reason = Order.ExpiryReasons.NTAKEN + + take_orders_queryset = TakeOrder.objects.filter(order=order) + for idx, take_order in enumerate(take_orders_queryset): + take_order.cancel(cls) + order.save(update_fields=["expiry_reason"]) + send_notification.delay(order_id=order.id, message="order_expired_untaken") order.log("Order expired while public or paused") @@ -292,17 +316,6 @@ def order_expires(cls, order): return True - elif order.status == Order.Status.TAK: - cls.cancel_bond(order.taker_bond) - cls.kick_taker(order) - - nostr_send_order_event.delay(order_id=order.id) - - order.log("Order expired while waiting for taker bond") - order.log("Taker bond was cancelled") - - 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 @@ -414,20 +427,18 @@ def order_expires(cls, order): return True @classmethod - def kick_taker(cls, order): + def kick_taker(cls, take_order): """The taker did not lock the taker_bond. Now he has to go""" + take_order.cancel(cls) # Add a time out to the taker - if order.taker: - robot = order.taker.robot + if take_order.taker: + robot = take_order.taker.robot robot.penalty_expiration = timezone.now() + timedelta( seconds=PENALTY_TIMEOUT ) robot.save(update_fields=["penalty_expiration"]) - # Make order public again - cls.publish_order(order) - - order.log("Taker was kicked out of the order") + take_order.order.log("Taker was kicked out of the order") return True @classmethod @@ -1028,62 +1039,46 @@ def cancel_order(cls, order, user, state=None): return True, None - # 2.a) When maker cancels after bond - # - # The order disapears from book and goes to cancelled. If strict, maker is charged the bond - # to prevent DDOS on the LN node and order book. If not strict, maker is returned - # the bond (more user friendly). - elif ( - order.status in [Order.Status.PUB, Order.Status.PAU] and order.maker == user - ): - # Return the maker bond (Maker gets returned the bond for cancelling public order) - if cls.return_bond(order.maker_bond): - order.update_status(Order.Status.UCA) - send_notification.delay( - order_id=order.id, message="public_order_cancelled" - ) - - order.log("Order cancelled by maker while public or paused") - order.log("Maker bond was unlocked") + elif order.status in [Order.Status.PUB, Order.Status.PAU]: + if order.maker == user: + # 2.a) When maker cancels after bond + # + # The order disapears from book and goes to cancelled. If strict, maker is charged the bond + # to prevent DDOS on the LN node and order book. If not strict, maker is returned + # the bond (more user friendly). + # Return the maker bond (Maker gets returned the bond for cancelling public order) + if cls.return_bond(order.maker_bond): + order.update_status(Order.Status.UCA) + + order.log("Order cancelled by maker while public or paused") + order.log("Maker bond was unlocked") - nostr_send_order_event.delay(order_id=order.id) + take_orders_queryset = TakeOrder.objects.filter(order=order) + for idx, take_order in enumerate(take_orders_queryset): + order.log("Pretaker bond was unlocked") + take_order.cancel(cls) - return True, None + send_notification.delay( + order_id=order.id, message="public_order_cancelled" + ) + nostr_send_order_event.delay(order_id=order.id) - # 2.b) When maker cancels after bond and before taker bond is locked - # - # The order dissapears from book and goes to cancelled. - # The bond maker bond is returned. - elif order.status == Order.Status.TAK and order.maker == user: - # Return the maker bond (Maker gets returned the bond for cancelling public order) - if cls.return_bond(order.maker_bond): - cls.cancel_bond(order.taker_bond) - order.update_status(Order.Status.UCA) - send_notification.delay( - order_id=order.id, message="public_order_cancelled" + return True, None + else: + # 2.b) When pretaker cancels before bond + # LNPayment "take_order" is expired + take_order_query = TakeOrder.objects.filter( + order=order, taker=user, expires_at__gt=timezone.now() ) - order.log("Order cancelled by maker before the taker locked the bond") - order.log("Maker bond was unlocked") - order.log("Taker bond was cancelled") + if take_order_query.exists(): + take_order = take_order_query.first() + # adds a timeout penalty + cls.kick_taker(take_order) - nostr_send_order_event.delay(order_id=order.id) - - return True, None + order.log("Taker cancelled before locking the bond") - # 3) When taker cancels before bond - # The order goes back to the book as public. - # LNPayment "order.taker_bond" is deleted() - elif order.status == Order.Status.TAK and order.taker == user: - # adds a timeout penalty - cls.cancel_bond(order.taker_bond) - cls.kick_taker(order) - - order.log("Taker cancelled before locking the bond") - - nostr_send_order_event.delay(order_id=order.id) - - return True, None + return True, None # 4) When taker or maker cancel after bond (before escrow) # @@ -1186,11 +1181,10 @@ def cancel_order(cls, order, user, state=None): ) return True, None - else: - order.log( - f"Cancel request was sent by Robot({user.robot.id},{user.username}) on an invalid status {order.status}: {Order.Status(order.status).label}" - ) - return False, {"bad_request": "You cannot cancel this order"} + order.log( + f"Cancel request was sent by Robot({user.robot.id},{user.username}) on an invalid status {order.status}: {Order.Status(order.status).label}" + ) + return False, {"bad_request": "You cannot cancel this order"} @classmethod def collaborative_cancel(cls, order): @@ -1336,14 +1330,19 @@ def gen_maker_hold_invoice(cls, order, user): } @classmethod - def finalize_contract(cls, order): + def finalize_contract(cls, take_order): """When the taker locks the taker_bond the contract is final""" + order = take_order.order + order.taker = take_order.taker + order.taker_bond = take_order.taker_bond # 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) order.last_satoshis = cls.satoshis_now(order) order.last_satoshis_time = timezone.now() + if take_order.amount: + order.amount = take_order.amount # With the bond confirmation the order is extended 'public_order_duration' hours order.expires_at = timezone.now() + timedelta( @@ -1353,6 +1352,9 @@ def finalize_contract(cls, order): order.save( update_fields=[ "status", + "taker", + "taker_bond", + "amount", "last_satoshis", "last_satoshis_time", "expires_at", @@ -1368,6 +1370,9 @@ def finalize_contract(cls, order): order.maker.robot.save(update_fields=["total_contracts"]) order.taker.robot.save(update_fields=["total_contracts"]) + take_order.expires_at = timezone.now() + take_order.save(update_fields=["expires_at"]) + # Log a market tick try: market_tick = MarketTick.log_a_tick(order) @@ -1387,30 +1392,35 @@ def finalize_contract(cls, order): @classmethod def gen_taker_hold_invoice(cls, order, user): + take_order = TakeOrder.objects.filter( + taker=user, order=order, expires_at__gt=timezone.now() + ).first() + # Do not gen and kick out the taker if order is older than expiry time - if order.expires_at < timezone.now(): + if order.expires_at < timezone.now() or take_order.expires_at < timezone.now(): cls.order_expires(order) return False, { - "bad_request": "Invoice expired. You did not confirm taking the order in time." + "bad_request": "Order expired. You did not confirm taking the order in time." } # Do not gen if a taker invoice exist. Do not return if it is already locked. Return the old one if still waiting. - if order.taker_bond: + if take_order.taker_bond: return True, { - "bond_invoice": order.taker_bond.invoice, - "bond_satoshis": order.taker_bond.num_satoshis, + "bond_invoice": take_order.taker_bond.invoice, + "bond_satoshis": take_order.taker_bond.num_satoshis, + "expires_at": take_order.expires_at, } # If there was no taker_bond object yet, generates one - order.last_satoshis = cls.satoshis_now(order) - order.last_satoshis_time = timezone.now() - bond_satoshis = int(order.last_satoshis * order.bond_size / 100) - pos_text = "Buying" if cls.is_buyer(order, user) else "Selling" + take_order.last_satoshis = cls.satoshis_now(take_order.order) + take_order.last_satoshis_time = timezone.now() + bond_satoshis = int(take_order.last_satoshis * take_order.order.bond_size / 100) + pos_text = "Buying" if cls.is_buyer(take_order.order, user) else "Selling" if user.robot.wants_stealth: - description = f"{config("NODE_ALIAS")} - Payment reference: {order.reference}. This payment WILL FREEZE IN YOUR WALLET, check on RoboSats if the lock was successful. It will be unlocked (fail) unless you cheat or cancel unilaterally." + description = f"{config("NODE_ALIAS")} - Payment reference: {take_order.order.reference}. This payment WILL FREEZE IN YOUR WALLET, check on RoboSats if the lock was successful. It will be unlocked (fail) unless you cheat or cancel unilaterally." else: description = ( - f"{config("NODE_ALIAS")} - Taking 'Order {order.id}' {pos_text} BTC for {str(float(order.amount)) + Currency.currency_dict[str(order.currency.currency)]}" + f"{config("NODE_ALIAS")} - Taking 'Order {take_order.order.id}' {pos_text} BTC for {str(float(take_order.amount)) + Currency.currency_dict[str(take_order.order.currency.currency)]}" + " - Taker bond - This payment WILL FREEZE IN YOUR WALLET, check on RoboSats if the lock was successful. It will be unlocked (fail) unless you cheat or cancel unilaterally." ) @@ -1419,9 +1429,11 @@ def gen_taker_hold_invoice(cls, order, user): hold_payment = LNNode.gen_hold_invoice( bond_satoshis, description, - invoice_expiry=order.t_to_expire(Order.Status.TAK), - cltv_expiry_blocks=cls.compute_cltv_expiry_blocks(order, "taker_bond"), - order_id=order.id, + invoice_expiry=take_order.order.t_to_expire(Order.Status.TAK), + cltv_expiry_blocks=cls.compute_cltv_expiry_blocks( + take_order.order, "taker_bond" + ), + order_id=take_order.order.id, lnpayment_concept=LNPayment.Concepts.TAKEBOND.label, time=int(timezone.now().timestamp()), ) @@ -1432,7 +1444,7 @@ def gen_taker_hold_invoice(cls, order, user): "bad_request": "The Lightning Network Daemon (LND) is down. Write in the Telegram group to make sure the staff is aware." } - order.taker_bond = LNPayment.objects.create( + take_order.taker_bond = LNPayment.objects.create( concept=LNPayment.Concepts.TAKEBOND, type=LNPayment.Types.HOLD, sender=user, @@ -1448,25 +1460,27 @@ def gen_taker_hold_invoice(cls, order, user): cltv_expiry=hold_payment["cltv_expiry"], ) - order.expires_at = timezone.now() + timedelta( + take_order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.TAK) ) - order.save( + take_order.save( update_fields=[ "expires_at", + "last_satoshis", "last_satoshis_time", "taker_bond", "expires_at", ] ) - order.log( + take_order.order.log( f"Taker bond invoice LNPayment({hold_payment['payment_hash']},{str(order.taker_bond)}) was created" ) return True, { "bond_invoice": hold_payment["invoice"], "bond_satoshis": bond_satoshis, + "expires_at": take_order.expires_at, } def trade_escrow_received(order): diff --git a/api/management/commands/clean_orders.py b/api/management/commands/clean_orders.py index ded403356..157064481 100644 --- a/api/management/commands/clean_orders.py +++ b/api/management/commands/clean_orders.py @@ -4,7 +4,7 @@ from django.utils import timezone from api.logics import Logics -from api.models import Order +from api.models import Order, TakeOrder class Command(BaseCommand): @@ -44,6 +44,15 @@ def clean_orders(self): if Logics.order_expires(order): # Order send to expire here debug["expired_orders"].append({idx: context}) + # expire all related take orders + take_orders_queryset = TakeOrder.objects.filter( + order=order, expires_at__gt=timezone.now() + ) + for idx, take_order in enumerate(take_orders_queryset): + take_order.expires_at = order.expires_at + take_order.save() + Logics.take_order_expires(take_order) + # It should not happen, but if it cannot locate the hold invoice # it probably was cancelled by another thread, make it expire anyway. except Exception as e: @@ -59,6 +68,34 @@ def clean_orders(self): self.stdout.write(str(timezone.now())) self.stdout.write(str(debug)) + take_orders_queryset = TakeOrder.objects.filter(expires_at__lt=timezone.now()) + debug["num_expired_take_orders"] = len(take_orders_queryset) + debug["expired_take_orders"] = [] + debug["failed_take_order_expiry"] = [] + debug["reason_take_failure"] = [] + + for idx, take_order in enumerate(take_orders_queryset): + context = str(take_order) + " was expired" + try: + if Logics.take_order_expires( + take_order + ): # Take order send to expire here + debug["expired_take_orders"].append({idx: context}) + + # It should not happen, but if it cannot locate the hold invoice + # it probably was cancelled by another thread, make it expire anyway. + except Exception as e: + debug["failed_take_order_expiry"].append({idx: context}) + debug["reason_take_failure"].append({idx: str(e)}) + + if "unable to locate invoice" in str(e): + self.stdout.write(str(e)) + debug["expired_take_orders"].append({idx: context}) + + if debug["num_expired_take_orders"] > 0: + self.stdout.write(str(timezone.now())) + self.stdout.write(str(debug)) + def handle(self, *args, **options): """Never mind database locked error, keep going, print them out. Not an issue with PostgresQL""" diff --git a/api/management/commands/follow_invoices.py b/api/management/commands/follow_invoices.py index 8b4b98c18..c8f79c371 100644 --- a/api/management/commands/follow_invoices.py +++ b/api/management/commands/follow_invoices.py @@ -234,12 +234,20 @@ def update_order_status(self, lnpayment): ) return - # It is a taker bond => close contract. - elif hasattr(lnpayment, "order_taken"): - if lnpayment.order_taken.status == Order.Status.TAK: - lnpayment.order_taken.log("Taker bond locked") - Logics.finalize_contract(lnpayment.order_taken) - return + # It is a taker bond + elif hasattr(lnpayment, "take_order"): + if lnpayment.take_order.order.status == Order.Status.PUB: + # It there was no other taker already locked => close contract. + lnpayment.take_order.order.log("Taker bond locked") + Logics.finalize_contract(lnpayment.take_order) + else: + # It there was another taker already locked => cancel bond. + lnpayment.take_order.order.log( + "Another taker bond is already locked, Cancelling" + ) + Logics.take_order_expires(lnpayment.take_order) + + return # It is a trade escrow => move foward order status. elif hasattr(lnpayment, "order_escrow"): @@ -272,14 +280,20 @@ def update_order_status(self, lnpayment): Logics.order_expires(lnpayment.order_made) return - elif hasattr(lnpayment, "order_taken"): - Logics.order_expires(lnpayment.order_taken) + elif hasattr(lnpayment, "take_order"): + Logics.take_order_expires(lnpayment.take_order) + if hasattr(lnpayment, "order_taken"): + Logics.order_expires(lnpayment.order_taken) return elif hasattr(lnpayment, "order_escrow"): Logics.order_expires(lnpayment.order_escrow) return + elif hasattr(lnpayment, "take_order"): + Logics.take_order_expires(lnpayment.order_escrow) + return + # TODO If a lnpayment goes from LOCKED to INVGEN. Totally weird # halt the order elif lnpayment.status == LNPayment.Status.INVGEN: diff --git a/api/migrations/0051_takeorder.py b/api/migrations/0051_takeorder.py new file mode 100644 index 000000000..fa85f34f4 --- /dev/null +++ b/api/migrations/0051_takeorder.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.4 on 2025-02-24 13:10 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0050_alter_order_status'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TakeOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(blank=True, decimal_places=8, max_digits=18, null=True)), + ('expires_at', models.DateTimeField()), + ('last_satoshis', models.PositiveBigIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000000)])), + ('last_satoshis_time', models.DateTimeField(blank=True, default=None, null=True)), + ('order', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order', to='api.order')), + ('taker', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pretaker', to=settings.AUTH_USER_MODEL)), + ('taker_bond', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.lnpayment')), + ], + ), + ] diff --git a/api/models/__init__.py b/api/models/__init__.py index 645a7fac7..d3eabc057 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -5,6 +5,7 @@ from .order import Order from .robot import Robot from .notification import Notification +from .take_order import TakeOrder __all__ = [ "Currency", @@ -14,4 +15,5 @@ "Order", "Robot", "Notification", + "TakeOrder", ] diff --git a/api/models/take_order.py b/api/models/take_order.py new file mode 100644 index 000000000..6d191c30e --- /dev/null +++ b/api/models/take_order.py @@ -0,0 +1,51 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.contrib.auth.models import User +from django.db import models +from django.conf import settings +from django.utils import timezone + + +class TakeOrder(models.Model): + amount = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True) + order = models.ForeignKey( + "api.Order", + related_name="order", + on_delete=models.CASCADE, + null=False, + default=None, + blank=False, + ) + taker = models.ForeignKey( + User, + related_name="pretaker", + on_delete=models.CASCADE, + null=False, + default=None, + blank=False, + ) + expires_at = models.DateTimeField() + taker_bond = models.OneToOneField( + "api.LNPayment", + related_name="take_order", + on_delete=models.SET_NULL, + null=True, + default=None, + blank=True, + ) + last_satoshis = models.PositiveBigIntegerField( + null=True, + default=None, + validators=[MinValueValidator(0), MaxValueValidator(settings.MAX_TRADE * 2)], + blank=True, + ) + # timestamp of last_satoshis + last_satoshis_time = models.DateTimeField(null=True, default=None, blank=True) + + def cancel(self, cls): + if self.expires_at > timezone.now(): + self.expires_at = timezone.now() + self.save(update_fields=["expires_at"]) + cls.cancel_bond(self.taker_bond) + + def __str__(self): + return f"Order {self.order.id} taken by Robot({self.taker.robot.id},{self.taker.username}) for {self.amount} fiat units" diff --git a/api/oas_schemas.py b/api/oas_schemas.py index 8d214f327..41dc6f765 100644 --- a/api/oas_schemas.py +++ b/api/oas_schemas.py @@ -85,6 +85,7 @@ class OrderViewSchema: - `total_secs_exp` - `penalty` - `is_maker` + - `is_pretaker` - `is_taker` - `is_participant` - `maker_status` diff --git a/api/serializers.py b/api/serializers.py index 46fb1c582..cbe06ce22 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -199,6 +199,9 @@ class OrderDetailSerializer(serializers.ModelSerializer): is_maker = serializers.BooleanField( required=False, help_text="Whether you are the maker or not" ) + is_pretaker = serializers.BooleanField( + required=False, help_text="Whether you are a pre-taker or not" + ) is_taker = serializers.BooleanField( required=False, help_text="Whether you are the taker or not" ) @@ -429,6 +432,7 @@ class Meta: "total_secs_exp", "penalty", "is_maker", + "is_pretaker", "is_taker", "is_participant", "maker_status", diff --git a/api/views.py b/api/views.py index a03dbd33d..162849e3c 100644 --- a/api/views.py +++ b/api/views.py @@ -23,6 +23,7 @@ OnchainPayment, Order, Notification, + TakeOrder, ) from api.notifications import Notifications from api.oas_schemas import ( @@ -245,10 +246,22 @@ def get(self, request, format=None): # Add booleans if user is maker, taker, partipant, buyer or seller data["is_maker"] = order.maker == request.user data["is_taker"] = order.taker == request.user - data["is_participant"] = data["is_maker"] or data["is_taker"] + data["is_pretaker"] = ( + not data["is_taker"] + and TakeOrder.objects.filter( + taker=request.user, order=order, expires_at__gt=timezone.now() + ).exists() + ) + data["is_participant"] = ( + data["is_maker"] or data["is_taker"] or data["is_pretaker"] + ) # 3.a) If not a participant and order is not public, forbid. - if not data["is_participant"] and order.status != Order.Status.PUB: + if ( + not data["is_maker"] + and not data["is_taker"] + and order.status != Order.Status.PUB + ): return Response( {"bad_request": "This order is not available"}, status.HTTP_403_FORBIDDEN, @@ -355,9 +368,16 @@ def get(self, request, format=None): else: return Response(context, status.HTTP_400_BAD_REQUEST) - # 6) If status is 'waiting for taker bond' and user is TAKER, reply with a TAKER hold invoice. - elif order.status == Order.Status.TAK and data["is_taker"]: + # 6) If status is 'Public' and user is PRETAKER, reply with a TAKER hold invoice. + elif ( + order.status == Order.Status.PUB + and data["is_pretaker"] + and not data["is_taker"] + ): + data["total_secs_exp"] = order.t_to_expire(Order.Status.TAK) + valid, context = Logics.gen_taker_hold_invoice(order, request.user) + if valid: data = {**data, **context} else: @@ -547,14 +567,20 @@ def take_update_confirm_dispute_cancel(self, request, format=None): status.HTTP_400_BAD_REQUEST, ) + # 2) If action is cancel + elif action == "cancel": + valid, context = Logics.cancel_order(order, request.user) + if not valid: + return Response(context, status.HTTP_400_BAD_REQUEST) + # Any other action is only allowed if the user is a participant - if not (order.maker == request.user or order.taker == request.user): + elif not (order.maker == request.user or order.taker == request.user): return Response( {"bad_request": "You are not a participant in this order"}, status.HTTP_403_FORBIDDEN, ) - # 2) If action is 'update invoice' + # 3) If action is 'update invoice' elif action == "update_invoice": # DEPRECATE post v0.5.1. valid_signature, invoice = verify_signed_message( @@ -573,7 +599,7 @@ def take_update_confirm_dispute_cancel(self, request, format=None): if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 2.b) If action is 'update address' + # 3.b) If action is 'update address' elif action == "update_address": valid_signature, address = verify_signed_message( request.user.robot.public_key, pgp_address @@ -591,25 +617,19 @@ def take_update_confirm_dispute_cancel(self, request, format=None): if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 3) If action is cancel - elif action == "cancel": - valid, context = Logics.cancel_order(order, request.user) - if not valid: - return Response(context, status.HTTP_400_BAD_REQUEST) - - # 4) If action is confirm + # 5) If action is confirm elif action == "confirm": valid, context = Logics.confirm_fiat(order, request.user) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 4.b) If action is confirm + # 5.b) If action is confirm elif action == "undo_confirm": valid, context = Logics.undo_confirm_fiat_sent(order, request.user) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 5) If action is dispute + # 6) If action is dispute elif action == "dispute": valid, context = Logics.open_dispute(order, request.user) if not valid: @@ -620,18 +640,18 @@ def take_update_confirm_dispute_cancel(self, request, format=None): if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 6) If action is rate + # 7) If action is rate elif action == "rate_user" and rating: """No user rating""" pass - # 7) If action is rate_platform + # 8) If action is rate_platform elif action == "rate_platform" and rating: valid, context = Logics.rate_platform(request.user, rating) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 8) If action is rate_platform + # 9) If action is rate_platform elif action == "pause": valid, context = Logics.pause_unpause_public_order(order, request.user) if not valid: diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index 4d6f0761c..b8cba8aaa 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -338,6 +338,7 @@ paths: - `total_secs_exp` - `penalty` - `is_maker` + - `is_pretaker` - `is_taker` - `is_participant` - `maker_status` @@ -1386,6 +1387,9 @@ components: is_maker: type: boolean description: Whether you are the maker or not + is_pretaker: + type: boolean + description: Whether you are a pre-taker or not is_taker: type: boolean description: Whether you are the taker or not diff --git a/frontend/src/models/Order.model.ts b/frontend/src/models/Order.model.ts index ede9e4bdf..a02a7e80e 100644 --- a/frontend/src/models/Order.model.ts +++ b/frontend/src/models/Order.model.ts @@ -75,6 +75,7 @@ class Order { penalty: Date | undefined = undefined; is_maker: boolean = false; is_taker: boolean = false; + is_pretaker: boolean = false; is_participant: boolean = false; maker_status: 'Active' | 'Seen recently' | 'Inactive' = 'Active'; taker_status: 'Active' | 'Seen recently' | 'Inactive' = 'Active'; diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 1b002e362..398d6800f 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -714,4 +714,4 @@ "This order has been cancelled collaborativelly": "This order has been cancelled collaboratively", "This order is not available": "Esta ordem não está disponível", "The Robotic Satoshis working in the warehouse did not understand you. Please, fill a Bug Issue in Github https://github.com/RoboSats/robosats/issues": "The Robotic Satoshis working in the warehouse did not understand you. Please, fill a Bug Issue in Github https://github.com/RoboSats/robosats/issues" -} \ No newline at end of file +} diff --git a/setup.md b/setup.md index 2dc4d5502..09b6bd8a5 100644 --- a/setup.md +++ b/setup.md @@ -35,6 +35,7 @@ docker-compose up Then visit `127.0.0.1:4000` on your browser. Once you save changes on a file it will take around 10s for the site to update (press to force-refresh your browser). # Full Stack Development + ## The Easy Way: Docker-compose (-dev containers running on testnet) *Set up time, anywhere between ~45 min and 1 full day (depending on experience, whether you have a copy of the testnet blockchain, etc). Tested in Ubuntu. @@ -48,7 +49,24 @@ docker-compose restart ``` Copy the `.env-sample` file into `.env` and check the environmental variables are right for your development. -**All set!** +## Running tests + +Build and run containers with the test specific configuration: +``` +docker compose -f docker-tests.yml --env-file ./tests/compose.env up -d +``` + +Run tests: +``` +docker exec -it test-coordinator coverage run manage.py test +``` + +If you want to run tests with CLN: +``` +LNVENDOR='CLN' +``` + +## All set! Commands you will need to startup: diff --git a/tests/robots/3/b91_token b/tests/robots/3/b91_token new file mode 100644 index 000000000..e5c9896c3 --- /dev/null +++ b/tests/robots/3/b91_token @@ -0,0 +1 @@ +CUNL>XIEm:ID9E3dhvQN timedelta(minutes=2) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=5) + ) self.assertTrue(data["is_maker"]) self.assertTrue(data["is_participant"]) self.assertTrue(data["is_buyer"]) @@ -343,6 +352,14 @@ def test_publish_order(self): self.assertTrue(data["maker_locked"]) self.assertFalse(data["taker_locked"]) self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=1150) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=1160) + ) # Test what we can see with newly created robot 2 (only for public status) trade.get_order(robot_index=2, first_encounter=True) @@ -407,28 +424,120 @@ def test_make_and_take_order(self): self.assertEqual(trade.response.status_code, 200) self.assertResponse(trade.response) - self.assertEqual(data["status_message"], Order.Status(Order.Status.TAK).label) + self.assertEqual(data["status_message"], Order.Status(Order.Status.PUB).label) self.assertEqual( data["ur_nick"], read_file(f"tests/robots/{trade.taker_index}/nickname") ) + self.assertEqual(data["taker_nick"], "None") + self.assertEqual( + data["maker_nick"], read_file(f"tests/robots/{trade.maker_index}/nickname") + ) + self.assertIsHash(data["maker_hash_id"]) + self.assertEqual(data["maker_status"], "Active") + self.assertFalse(data["is_maker"]) + self.assertFalse(data["is_buyer"]) + self.assertFalse(data["is_seller"]) + self.assertFalse(data["is_taker"]) + self.assertTrue(data["is_participant"]) + self.assertTrue(data["is_pretaker"]) + self.assertTrue(data["maker_locked"]) + self.assertFalse(data["taker_locked"]) + self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=2) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=4) + ) + + # Cancel order to avoid leaving pending HTLCs after a successful test + trade.cancel_order() + + self.assert_order_logs(data["id"]) + + def test_make_and_take_order_multiple_takers(self): + """ + Tests a trade from order creation to taken. + """ + trade = Trade(self.client) + trade.publish_order() + + # Third TAKE + trade.take_order_third() + + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + self.assertEqual(data["status_message"], Order.Status(Order.Status.PUB).label) self.assertEqual( - data["taker_nick"], read_file(f"tests/robots/{trade.taker_index}/nickname") + data["ur_nick"], read_file(f"tests/robots/{trade.third_index}/nickname") ) + self.assertEqual(data["taker_nick"], "None") + self.assertEqual( + data["maker_nick"], read_file(f"tests/robots/{trade.maker_index}/nickname") + ) + self.assertIsHash(data["maker_hash_id"]) + self.assertEqual(data["maker_status"], "Active") + self.assertFalse(data["is_maker"]) + self.assertFalse(data["is_buyer"]) + self.assertFalse(data["is_seller"]) + self.assertFalse(data["is_taker"]) + self.assertTrue(data["is_participant"]) + self.assertTrue(data["is_pretaker"]) + self.assertTrue(data["maker_locked"]) + self.assertFalse(data["taker_locked"]) + self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=2) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=4) + ) + + third_invoice = data["bond_invoice"] + + ## Maker TAKE + trade.take_order() + + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + self.assertNotEqual(third_invoice, data["bond_invoice"]) + self.assertEqual(data["status_message"], Order.Status(Order.Status.PUB).label) + self.assertEqual( + data["ur_nick"], read_file(f"tests/robots/{trade.taker_index}/nickname") + ) + self.assertEqual(data["taker_nick"], "None") self.assertEqual( data["maker_nick"], read_file(f"tests/robots/{trade.maker_index}/nickname") ) self.assertIsHash(data["maker_hash_id"]) - self.assertIsHash(data["taker_hash_id"]) self.assertEqual(data["maker_status"], "Active") - self.assertEqual(data["taker_status"], "Active") self.assertFalse(data["is_maker"]) self.assertFalse(data["is_buyer"]) - self.assertTrue(data["is_seller"]) - self.assertTrue(data["is_taker"]) + self.assertFalse(data["is_seller"]) + self.assertFalse(data["is_taker"]) self.assertTrue(data["is_participant"]) + self.assertTrue(data["is_pretaker"]) self.assertTrue(data["maker_locked"]) self.assertFalse(data["taker_locked"]) self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=2) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=4) + ) # Cancel order to avoid leaving pending HTLCs after a successful test trade.cancel_order() @@ -456,6 +565,14 @@ def test_make_and_lock_contract(self): self.assertTrue(data["maker_locked"]) self.assertTrue(data["taker_locked"]) self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=140) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=150) + ) # Maker GET trade.get_order(trade.maker_index) @@ -476,12 +593,101 @@ def test_make_and_lock_contract(self): self.assertTrue(data["maker_locked"]) self.assertTrue(data["taker_locked"]) self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=140) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=150) + ) # Maker cancels order to avoid leaving pending HTLCs after a successful test trade.cancel_order() self.assert_order_logs(data["id"]) + def test_make_and_lock_contract_multiple_takers(self): + """ + Tests a trade from order creation to taker bond locked where a third Robot is involved. + """ + trade = Trade(self.client) + trade.publish_order() + trade.take_order() + trade.take_order_third() + + # Both taker and Third pays at the same time, being the taker the first to resolve + third_invoice = trade.response.json()["bond_invoice"] + trade.get_order(trade.taker_index) + taker_invoice = trade.response.json()["bond_invoice"] + trade.pay_invoice(taker_invoice) + trade.pay_invoice(third_invoice) + trade.follow_hold_invoices() + trade.get_order(trade.taker_index) + + # Taker GET + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + self.assertEqual(data["status_message"], Order.Status(Order.Status.WF2).label) + self.assertEqual(data["maker_status"], "Active") + self.assertEqual(data["taker_status"], "Active") + self.assertTrue(data["is_participant"]) + self.assertTrue(data["maker_locked"]) + self.assertTrue(data["taker_locked"]) + self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=140) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=150) + ) + + self.assert_order_logs(data["id"]) + + # Maker GET + trade.get_order(trade.maker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + self.assertEqual(data["status_message"], Order.Status(Order.Status.WF2).label) + self.assertTrue(data["swap_allowed"]) + self.assertIsInstance(data["suggested_mining_fee_rate"], float) + self.assertIsInstance(data["swap_fee_rate"], float) + self.assertTrue(data["suggested_mining_fee_rate"] > 0) + self.assertTrue(data["swap_fee_rate"] > 0) + self.assertEqual(data["maker_status"], "Active") + self.assertEqual(data["taker_status"], "Active") + self.assertTrue(data["is_participant"]) + self.assertTrue(data["maker_locked"]) + self.assertTrue(data["taker_locked"]) + self.assertFalse(data["escrow_locked"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + > timedelta(minutes=140) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["expires_at"]) - timezone.now()) + < timedelta(minutes=150) + ) + + self.assert_order_logs(data["id"]) + + # third GET + trade.get_order(trade.third_index) + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 403) + self.assertEqual(data["bad_request"], "This order is not available") + + # Maker cancels order to avoid leaving pending HTLCs after a successful test + trade.cancel_order() + def test_trade_to_locked_escrow(self): """ Tests a trade from order creation until escrow locked, before @@ -490,6 +696,7 @@ def test_trade_to_locked_escrow(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) @@ -514,6 +721,7 @@ def test_trade_to_submitted_address(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_address(trade.maker_index) @@ -559,6 +767,7 @@ def test_trade_to_submitted_invoice(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -603,6 +812,7 @@ def test_trade_to_confirm_fiat_sent_LN(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -635,6 +845,7 @@ def test_trade_to_confirm_fiat_received_LN(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -681,6 +892,7 @@ def test_successful_LN(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -710,6 +922,7 @@ def test_successful_onchain(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_address(trade.maker_index) @@ -760,6 +973,118 @@ def test_cancel_public_order(self): f"❌ Hey {maker_nick}, you have cancelled your public order with ID {trade.order_id}.", ) + def test_cancel_public_order_by_taker(self): + """ + Tests the cancellation of a public order by a pretaker + """ + trade = Trade(self.client) + trade.publish_order() + + trade.take_order() + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + self.assertTrue(data["is_pretaker"]) + self.assertFalse(data["is_taker"]) + + trade.cancel_order(trade.taker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertFalse(data["is_participant"]) + self.assertFalse(data["is_pretaker"]) + self.assertFalse(data["is_taker"]) + self.assertFalse(data["is_maker"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["penalty"]) - timezone.now()) + > timedelta(minutes=0) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["penalty"]) - timezone.now()) + < timedelta(minutes=2) + ) + + trade.get_order(trade.maker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + self.assertTrue(data["is_maker"]) + + def test_cancel_public_order_by_third(self): + """ + Tests the cancellation of a public order by a third pretaker + """ + trade = Trade(self.client) + trade.publish_order() + trade.take_order() + + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + self.assertTrue(data["is_pretaker"]) + + trade.take_order_third() + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + self.assertTrue(data["is_pretaker"]) + + trade.cancel_order(trade.third_index) + + data = trade.response.json() + self.assertFalse(data["is_participant"]) + self.assertFalse(data["is_pretaker"]) + self.assertFalse(data["is_taker"]) + self.assertFalse(data["is_maker"]) + self.assertTrue( + (timezone.datetime.fromisoformat(data["penalty"]) - timezone.now()) + > timedelta(minutes=0) + ) + self.assertTrue( + (timezone.datetime.fromisoformat(data["penalty"]) - timezone.now()) + < timedelta(minutes=2) + ) + + trade.get_order(trade.maker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + self.assertTrue(data["is_maker"]) + + trade.get_order(trade.taker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + self.assertTrue(data["is_participant"]) + self.assertTrue(data["is_pretaker"]) + self.assertFalse(data["is_taker"]) + + def test_cancel_pretaken_order_by_maker(self): + """ + Tests the cancellation of a public order + """ + trade = Trade(self.client) + trade.publish_order() + trade.take_order() + trade.take_order_third() + + trade.cancel_order(trade.maker_index) + data = trade.response.json() + self.assertEqual( + data["bad_request"], "This order has been cancelled by the maker" + ) + + trade.get_order(trade.taker_index) + data = trade.response.json() + self.assertEqual( + data["bad_request"], "This order has been cancelled by the maker" + ) + + trade.get_order(trade.third_index) + data = trade.response.json() + self.assertEqual( + data["bad_request"], "This order has been cancelled by the maker" + ) + def test_collaborative_cancel_order_in_chat(self): """ Tests the collaborative cancellation of an order in the chat state @@ -767,6 +1092,7 @@ def test_collaborative_cancel_order_in_chat(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -891,6 +1217,7 @@ def test_taken_order_expires(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() # Change order expiry to now @@ -924,6 +1251,7 @@ def test_escrow_locked_expires(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) @@ -966,6 +1294,7 @@ def test_chat(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -1048,6 +1377,7 @@ def test_order_expires_after_only_taker_messaged(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -1109,6 +1439,7 @@ def test_order_expires_after_only_maker_messaged(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -1198,6 +1529,7 @@ def test_lightning_payment_failed(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) @@ -1224,6 +1556,7 @@ def test_withdraw_reward_after_unilateral_cancel(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.cancel_order(trade.maker_index) @@ -1261,6 +1594,7 @@ def test_order_expires_after_fiat_sent(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_address(trade.maker_index) @@ -1317,6 +1651,7 @@ def test_ticks(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.cancel_order() @@ -1342,6 +1677,7 @@ def test_daily_historical(self): trade = Trade(self.client) trade.publish_order() trade.take_order() + trade.take_order_third() trade.lock_taker_bond() trade.lock_escrow(trade.taker_index) trade.submit_payout_invoice(trade.maker_index) diff --git a/tests/utils/node.py b/tests/utils/node.py index d5cb1289e..002de9bcc 100644 --- a/tests/utils/node.py +++ b/tests/utils/node.py @@ -269,7 +269,7 @@ def pay_invoice(node_name, invoice): node = get_node(node_name) data = {"payment_request": invoice} try: - response = requests.post( + requests.post( f'http://localhost:{node["port"]}/v1/channels/transactions', json=data, headers=node["headers"], @@ -277,7 +277,6 @@ def pay_invoice(node_name, invoice): # 0.4s is enough for LND to CLN hodl ACCEPT timeout=0.2 if LNVENDOR == "LND" else 1, ) - print(response.json()) except ReadTimeout: # Request to pay hodl invoice has timed out: that's good! return diff --git a/tests/utils/trade.py b/tests/utils/trade.py index 39bcda0a1..1753495af 100644 --- a/tests/utils/trade.py +++ b/tests/utils/trade.py @@ -4,7 +4,7 @@ from api.management.commands.clean_orders import Command as CleanOrders from api.management.commands.follow_invoices import Command as FollowInvoices -from api.models import Order +from api.models import Order, TakeOrder from api.tasks import follow_send_payment, send_notification from tests.utils.node import ( add_invoice, @@ -50,12 +50,14 @@ def __init__( take_amount=80, maker_index=1, taker_index=2, + third_index=3, ): self.client = client self.maker_form = maker_form self.take_amount = take_amount self.maker_index = maker_index self.taker_index = taker_index + self.third_index = third_index self.make_order(self.maker_form, maker_index) @@ -178,6 +180,14 @@ def take_order(self): body = {"action": "take", "amount": self.take_amount} self.response = self.client.post(path + params, body, **headers) + @patch("api.tasks.send_notification.delay", send_notification) + def take_order_third(self): + path = reverse("order") + params = f"?order_id={self.order_id}" + headers = self.get_robot_auth(self.third_index, first_encounter=True) + body = {"action": "take", "amount": self.take_amount} + self.response = self.client.post(path + params, body, **headers) + @patch("api.tasks.send_notification.delay", send_notification) def lock_taker_bond(self): # Takers's first order fetch. Should trigger maker bond hold invoice generation. @@ -208,6 +218,11 @@ def lock_escrow(self, robot_index): # Get order self.get_order() + @patch("api.tasks.send_notification.delay", send_notification) + def pay_invoice(self, invoice): + # Lock the invoice from the robot's node + pay_invoice("robot", invoice) + @patch("api.tasks.send_notification.delay", send_notification) def submit_payout_address(self, robot_index=1): path = reverse("order") @@ -272,6 +287,11 @@ def expire_order(self): order.expires_at = datetime.now() order.save() + take_order_queryset = TakeOrder.objects.filter(order=order) + for idx, take_order in enumerate(take_order_queryset): + take_order.expires_at = datetime.now() + take_order.save() + @patch("api.tasks.send_notification.delay", send_notification) def change_order_status(self, status): # Change order expiry to now