diff --git a/api/migrations/0052_robot_nostr_pubkey_alter_takeorder_last_satoshis_and_more.py b/api/migrations/0052_robot_nostr_pubkey_alter_takeorder_last_satoshis_and_more.py new file mode 100644 index 00000000..9dc3a4f4 --- /dev/null +++ b/api/migrations/0052_robot_nostr_pubkey_alter_takeorder_last_satoshis_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.4 on 2025-04-22 14:40 + +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', '0051_takeorder'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='robot', + name='nostr_pubkey', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AlterField( + model_name='takeorder', + name='last_satoshis', + field=models.PositiveBigIntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000000)]), + ), + migrations.AlterField( + model_name='takeorder', + name='order', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='api.order'), + ), + migrations.AlterField( + model_name='takeorder', + name='taker', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='pretaker', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='takeorder', + name='taker_bond', + field=models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='take_order', to='api.lnpayment'), + ), + ] diff --git a/api/models/robot.py b/api/models/robot.py index c838d354..1579d6f7 100644 --- a/api/models/robot.py +++ b/api/models/robot.py @@ -42,6 +42,9 @@ class Robot(models.Model): telegram_lang_code = models.CharField(max_length=10, null=True, blank=True) telegram_welcomed = models.BooleanField(default=False, null=False) + # nostr + nostr_pubkey = models.CharField(max_length=64, null=True, blank=True) + # Claimable rewards earned_rewards = models.PositiveIntegerField(null=False, default=0) # Total claimed rewards diff --git a/api/nostr.py b/api/nostr.py index 481a2916..4fe356d9 100644 --- a/api/nostr.py +++ b/api/nostr.py @@ -2,6 +2,7 @@ import pygeohash import hashlib import uuid +from secp256k1 import PrivateKey, PublicKey, ALL_FLAGS from asgiref.sync import sync_to_async from nostr_sdk import Keys, Client, EventBuilder, NostrSigner, Kind, Tag from api.models import Order @@ -112,3 +113,24 @@ class Nostr: return ["onchain", "lightning"] else: return ["lightning"] + + def is_valid_public_key(public_key_hex): + try: + public_key_bytes = bytes.fromhex(public_key_hex) + PublicKey(public_key_bytes, raw=True) + return True + except Exception: + return False + + def sign_message(text: str) -> str: + try: + private_key = config("NOSTR_NSEC", cast=str) + privkey = PrivateKey( + bytes.fromhex(private_key), raw=True, ctx_flags=ALL_FLAGS + ) + hashed_message = hashlib.sha256(text.encode("utf-8")).digest() + signature = privkey.schnorr_sign(hashed_message) + + return signature.hex() + except Exception: + return "" diff --git a/api/oas_schemas.py b/api/oas_schemas.py index f2a7de0c..de426b5f 100644 --- a/api/oas_schemas.py +++ b/api/oas_schemas.py @@ -9,6 +9,7 @@ from api.serializers import ( ListOrderSerializer, OrderDetailSerializer, StealthSerializer, + ReviewSerializer, ) EXP_MAKER_BOND_INVOICE = int(config("EXP_MAKER_BOND_INVOICE")) @@ -786,3 +787,22 @@ class StealthViewSchema: }, }, } + + +class ReviewViewSchema: + post = { + "summary": "Generates a review token", + "description": "Generates the token necesary for reviews of robot's latest order", + "responses": { + 200: ReviewSerializer, + 400: { + "type": "object", + "properties": { + "bad_request": { + "type": "string", + "description": "Reason for the failure", + }, + }, + }, + }, + } diff --git a/api/serializers.py b/api/serializers.py index 7f88e7e6..18909f1c 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -679,5 +679,14 @@ class TickSerializer(serializers.ModelSerializer): depth = 0 +class ReviewSerializer(serializers.Serializer): + pubkey = serializers.CharField( + help_text="Robot's nostr hex pubkey", + allow_null=False, + allow_blank=False, + required=True, + ) + + class StealthSerializer(serializers.Serializer): wantsStealth = serializers.BooleanField() diff --git a/api/urls.py b/api/urls.py index 7264c1e6..e325f4a2 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,7 @@ from .views import ( RobotView, StealthView, TickView, + ReviewView, NotificationsView, ) @@ -38,4 +39,5 @@ urlpatterns = [ path("stealth/", StealthView.as_view(), name="stealth"), path("chat/", ChatView.as_view({"get": "get", "post": "post"}), name="chat"), path("notifications/", NotificationsView.as_view(), name="notifications"), + path("review/", ReviewView.as_view(), name="review"), ] diff --git a/api/views.py b/api/views.py index d0dd53d9..98aec2b7 100644 --- a/api/views.py +++ b/api/views.py @@ -37,6 +37,7 @@ from api.oas_schemas import ( RewardViewSchema, RobotViewSchema, StealthViewSchema, + ReviewViewSchema, TickViewSchema, NotificationSchema, ) @@ -49,6 +50,7 @@ from api.serializers import ( PriceSerializer, StealthSerializer, TickSerializer, + ReviewSerializer, UpdateOrderSerializer, ListNotificationSerializer, ) @@ -60,6 +62,7 @@ from api.utils import ( get_robosats_commit, verify_signed_message, ) +from api.nostr import Nostr from chat.models import Message from control.models import AccountingDay, BalanceLog @@ -1033,3 +1036,54 @@ class StealthView(APIView): request.user.robot.save(update_fields=["wants_stealth"]) return Response({"wantsStealth": stealth}) + + +class ReviewView(APIView): + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + serializer_class = ReviewSerializer + + @extend_schema(**ReviewViewSchema.post) + def post(self, request): + if config("NOSTR_NSEC", cast=str, default="") == "": + return Response(status=status.HTTP_400_BAD_REQUEST) + + serializer = self.serializer_class(data=request.data) + + if not serializer.is_valid(): + return Response(status=status.HTTP_400_BAD_REQUEST) + + pubkey = serializer.data.get("pubkey") + last_order = Order.objects.filter( + Q(maker=request.user) | Q(taker=request.user) + ).last() + + if not last_order or last_order.status not in [ + Order.Status.SUC, + Order.Status.MLD, + Order.Status.TLD, + ]: + return Response( + {"bad_request": "Robot has no finished order"}, + status.HTTP_400_BAD_REQUEST, + ) + if not request.user.robot.nostr_pubkey: + verified = Nostr.is_valid_public_key(pubkey) + if verified: + request.user.robot.nostr_pubkey = pubkey + request.user.robot.save(update_fields=["nostr_pubkey"]) + else: + return Response( + {"bad_request": "Invalid hex pubkey"}, + status.HTTP_400_BAD_REQUEST, + ) + if request.user.robot.nostr_pubkey is not pubkey: + return Response( + {"bad_request": "Wrong hex pubkey"}, + status.HTTP_400_BAD_REQUEST, + ) + + token = Nostr.sign_message(f"{pubkey}{last_order.id}") + + return Response({"pubkey": pubkey, "token": token}, status.HTTP_200_OK) diff --git a/requirements.txt b/requirements.txt index b8fdf2ea..046c9048 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,3 +31,4 @@ base91==1.0.1 nostr-sdk==0.35.1 pygeohash==1.2.0 asgiref == 3.8.1 +secp256k1 \ No newline at end of file