From ef5257ef3315a32e31d8332cceb8c467ad25abaa Mon Sep 17 00:00:00 2001 From: koalasat Date: Wed, 18 Jun 2025 15:15:31 +0200 Subject: [PATCH] Add password to Orders --- api/migrations/0054_order_password.py | 18 +++++++++ api/models/order.py | 8 ++++ api/nostr.py | 4 ++ api/serializers.py | 8 ++++ api/views.py | 37 +++++++++++++----- docs/assets/schemas/api-latest.yaml | 9 +++++ tests/test_trade_pipeline.py | 54 +++++++++++++++++++++++++++ tests/utils/trade.py | 23 ++++++++++++ 8 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 api/migrations/0054_order_password.py diff --git a/api/migrations/0054_order_password.py b/api/migrations/0054_order_password.py new file mode 100644 index 00000000..f5846dd0 --- /dev/null +++ b/api/migrations/0054_order_password.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.9 on 2025-06-18 12:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0053_alter_currency_currency'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='password', + field=models.TextField(blank=True, default=None, max_length=2000, null=True), + ), + ] diff --git a/api/models/order.py b/api/models/order.py index 483e6fa4..d1f3113b 100644 --- a/api/models/order.py +++ b/api/models/order.py @@ -165,6 +165,14 @@ class Order(models.Model): blank=True, ) + # optionally makers can set a password for the order to be taken + password = models.TextField( + max_length=2000, + null=True, + default=None, + blank=True, + ) + # how many sats at creation and at last check (relevant for marked to market) t0_satoshis = models.PositiveBigIntegerField( null=True, diff --git a/api/nostr.py b/api/nostr.py index a471be2b..94394f5c 100644 --- a/api/nostr.py +++ b/api/nostr.py @@ -15,6 +15,10 @@ class Nostr: async def send_order_event(self, order): """Creates the event and sends it to the coordinator relay""" + # Publish only public orders + if order.password is not None: + return + if config("NOSTR_NSEC", cast=str, default="") == "": return diff --git a/api/serializers.py b/api/serializers.py index 55704854..4fd41017 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -594,10 +594,18 @@ class MakeOrderSerializer(serializers.ModelSerializer): "bond_size", "latitude", "longitude", + "password", ) class UpdateOrderSerializer(serializers.Serializer): + password = serializers.CharField( + max_length=2000, + allow_null=True, + allow_blank=True, + default=None, + help_text="In case the order is password protected", + ) invoice = serializers.CharField( max_length=15000, allow_null=True, diff --git a/api/views.py b/api/views.py index 069303d1..603c15cd 100644 --- a/api/views.py +++ b/api/views.py @@ -119,6 +119,7 @@ class MakerView(CreateAPIView): bond_size = serializer.data.get("bond_size") latitude = serializer.data.get("latitude") longitude = serializer.data.get("longitude") + password = serializer.data.get("password") # Optional params if public_duration is None: @@ -173,6 +174,7 @@ class MakerView(CreateAPIView): bond_size=bond_size, latitude=latitude, longitude=longitude, + password=password, ) order.last_satoshis = order.t0_satoshis = Logics.satoshis_now(order) @@ -230,6 +232,23 @@ class OrderView(viewsets.ViewSet): # This is our order. order = order[0] + data = ListOrderSerializer(order).data + take_order = TakeOrder.objects.filter( + taker=request.user, order=order, expires_at__gt=timezone.now() + ) + + # 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 or take_order.exists() + data["is_participant"] = data["is_maker"] or data["is_taker"] + + # 1) If order has a password + if not data["is_participant"] and order.password is not None: + return Response( + {"bad_request": "This order is password protected"}, + status.HTTP_403_FORBIDDEN, + ) + # 2) If order has been cancelled if order.status == Order.Status.UCA: return Response( @@ -242,7 +261,6 @@ class OrderView(viewsets.ViewSet): status.HTTP_400_BAD_REQUEST, ) - data = ListOrderSerializer(order).data data["total_secs_exp"] = order.t_to_expire(order.status) # if user is under a limit (penalty), inform him. @@ -250,14 +268,6 @@ class OrderView(viewsets.ViewSet): if is_penalized: data["penalty"] = request.user.robot.penalty_expiration - # Add booleans if user is maker, taker, partipant, buyer or seller - take_order = TakeOrder.objects.filter( - taker=request.user, order=order, expires_at__gt=timezone.now() - ) - data["is_maker"] = order.maker == request.user - data["is_taker"] = order.taker == request.user or take_order.exists() - data["is_participant"] = data["is_maker"] or data["is_taker"] - # 3.a) If not a participant and order is not public, forbid. if ( order.maker != request.user @@ -550,6 +560,7 @@ class OrderView(viewsets.ViewSet): statement = serializer.data.get("statement") rating = serializer.data.get("rating") cancel_status = serializer.data.get("cancel_status") + password = serializer.data.get("password") # 1) If action is take, it is a taker request! if action == "take": @@ -558,6 +569,12 @@ class OrderView(viewsets.ViewSet): if not valid: return Response(context, status=status.HTTP_409_CONFLICT) + if order.password is not None and order.password != password: + return Response( + {"bad_request": "Wrong password"}, + status=status.HTTP_403_FORBIDDEN, + ) + # For order with amount range, set the amount now. if order.has_range: amount = float(serializer.data.get("amount")) @@ -734,7 +751,7 @@ class BookView(ListAPIView): currency = request.GET.get("currency", 0) type = request.GET.get("type", 2) - queryset = Order.objects.filter(status=Order.Status.PUB) + queryset = Order.objects.filter(status=Order.Status.PUB, password=None) # Currency 0 and type 2 are special cases treated as "ANY". (These are not really possible choices) if int(currency) == 0 and int(type) != 2: diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index 6521f21a..18851a4f 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -1328,6 +1328,10 @@ components: format: decimal pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ nullable: true + password: + type: string + nullable: true + maxLength: 2000 required: - currency - type @@ -1988,6 +1992,11 @@ components: UpdateOrder: type: object properties: + password: + type: string + nullable: true + description: In case the order is password protected + maxLength: 2000 invoice: type: string nullable: true diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index dc5b6083..b944f533 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -479,6 +479,60 @@ class TradeTest(BaseAPITestCase): # Cancel order to avoid leaving pending HTLCs after a successful test trade.cancel_order() + def test_make_and_take_password_order(self): + """ + Tests a trade with a password from order creation to taken. + """ + password = "1234567" + password_maker_form = maker_form_buy_with_range.copy() + password_maker_form["password"] = password + + trade = Trade( + self.client, + # add password to order + maker_form=password_maker_form, + ) + trade.publish_order() + + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + # Maker GET + trade.get_order(trade.maker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 200) + + # External user GET + trade.get_order(trade.taker_index) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 403) + self.assertEqual(data["bad_request"], "This order is password protected") + + # Take with no password + trade.take_order() + data = trade.response.json() + self.assertEqual(trade.response.status_code, 403) + self.assertEqual(data["bad_request"], "Wrong password") + + # Take with wrong password + trade.take_password_order("test") + data = trade.response.json() + self.assertEqual(trade.response.status_code, 403) + self.assertEqual(data["bad_request"], "Wrong password") + + # Take with right password + trade.take_password_order(password) + 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.assertAlmostEqual(float(data["amount"]), 100) + + # Cancel order to avoid leaving pending HTLCs after a successful test + trade.cancel_order() + def test_make_and_take_order_multiple_takers(self): """ Tests a trade from order creation to taken. diff --git a/tests/utils/trade.py b/tests/utils/trade.py index 8de3cbf2..1b01e34b 100644 --- a/tests/utils/trade.py +++ b/tests/utils/trade.py @@ -182,6 +182,21 @@ class Trade: # Get order self.get_order() + @patch("api.tasks.send_notification.delay", send_notification) + def publish_password_order(self): + # Maker's first order fetch. Should trigger maker bond hold invoice generation. + self.get_order() + invoice = self.response.json()["bond_invoice"] + + # Lock the invoice from the robot's node + pay_invoice("robot", invoice) + + # Check for invoice locked (the mocked LND will return ACCEPTED) + self.follow_hold_invoices() + + # Get order + self.get_order() + @patch("api.tasks.send_notification.delay", send_notification) def take_order(self): path = reverse("order") @@ -190,6 +205,14 @@ class Trade: 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_password_order(self, password): + path = reverse("order") + params = f"?order_id={self.order_id}" + headers = self.get_robot_auth(self.taker_index) + body = {"action": "take", "amount": self.take_amount, "password": password} + 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")