Skip to content

Commit

Permalink
Allow multiple takers
Browse files Browse the repository at this point in the history
  • Loading branch information
KoalaSat committed Feb 28, 2025
1 parent 17089ea commit ee4fe15
Show file tree
Hide file tree
Showing 22 changed files with 735 additions and 148 deletions.
3 changes: 2 additions & 1 deletion api/lightning/cln.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import hashlib
import os
import random
import secrets
import struct
import time
Expand Down Expand Up @@ -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
Expand Down
228 changes: 121 additions & 107 deletions api/logics.py

Large diffs are not rendered by default.

39 changes: 38 additions & 1 deletion api/management/commands/clean_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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"""
Expand Down
30 changes: 22 additions & 8 deletions api/management/commands/follow_invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b>locked</b>")
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 <b>locked</b>")
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, <b>Cancelling</b>"
)
Logics.take_order_expires(lnpayment.take_order)

return

# It is a trade escrow => move foward order status.
elif hasattr(lnpayment, "order_escrow"):
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions api/migrations/0051_takeorder.py
Original file line number Diff line number Diff line change
@@ -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')),
],
),
]
2 changes: 2 additions & 0 deletions api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .order import Order
from .robot import Robot
from .notification import Notification
from .take_order import TakeOrder

__all__ = [
"Currency",
Expand All @@ -14,4 +15,5 @@
"Order",
"Robot",
"Notification",
"TakeOrder",
]
51 changes: 51 additions & 0 deletions api/models/take_order.py
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions api/oas_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class OrderViewSchema:
- `total_secs_exp`
- `penalty`
- `is_maker`
- `is_pretaker`
- `is_taker`
- `is_participant`
- `maker_status`
Expand Down
4 changes: 4 additions & 0 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -429,6 +432,7 @@ class Meta:
"total_secs_exp",
"penalty",
"is_maker",
"is_pretaker",
"is_taker",
"is_participant",
"maker_status",
Expand Down
58 changes: 39 additions & 19 deletions api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
OnchainPayment,
Order,
Notification,
TakeOrder,
)
from api.notifications import Notifications
from api.oas_schemas import (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading

0 comments on commit ee4fe15

Please sign in to comment.