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