-
Notifications
You must be signed in to change notification settings - Fork 233
Expand file tree
/
Copy pathlogics.py
More file actions
2042 lines (1733 loc) · 81.1 KB
/
Copy pathlogics.py
File metadata and controls
2042 lines (1733 loc) · 81.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import math
from datetime import timedelta
from decouple import config, Csv
from django.contrib.auth.models import User
from django.db.models import Q, Sum
from django.utils import timezone
from django.utils.html import format_html
from django.db import transaction
from api.lightning.node import LNNode
from api.errors import new_error
from api.models import (
Currency,
LNPayment,
MarketTick,
OnchainPayment,
Order,
TakeOrder,
Robot,
)
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
FEE = float(config("FEE"))
MAKER_FEE_SPLIT = float(config("MAKER_FEE_SPLIT"))
ESCROW_USERNAME = config("ESCROW_USERNAME")
PENALTY_TIMEOUT = int(config("PENALTY_TIMEOUT"))
MIN_ORDER_SIZE = config("MIN_ORDER_SIZE", cast=int, default=20_000)
MAX_ORDER_SIZE = config("MAX_ORDER_SIZE", cast=int, default=500_000)
EXP_MAKER_BOND_INVOICE = int(config("EXP_MAKER_BOND_INVOICE"))
EXP_TAKER_BOND_INVOICE = int(config("EXP_TAKER_BOND_INVOICE"))
BLOCK_TIME = float(config("BLOCK_TIME"))
MAX_MINING_NETWORK_SPEEDUP_EXPECTED = float(
config("MAX_MINING_NETWORK_SPEEDUP_EXPECTED")
)
GEOBLOCKED_COUNTRIES = config("GEOBLOCKED_COUNTRIES", cast=Csv(), default="")
class Logics:
@classmethod
def validate_already_maker_or_taker(cls, user):
"""Validates if a use is already not part of an active order"""
active_order_status = [
Order.Status.WFB,
Order.Status.PUB,
Order.Status.PAU,
Order.Status.TAK,
Order.Status.WF2,
Order.Status.WFE,
Order.Status.WFI,
Order.Status.CHA,
Order.Status.FSE,
Order.Status.DIS,
Order.Status.WFR,
]
"""Checks if the user is already partipant of an active order"""
queryset_maker = Order.objects.filter(
maker=user, status__in=active_order_status
)
if queryset_maker.exists():
return (
False,
new_error(1000),
queryset_maker[0],
)
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,
new_error(1001),
queryset_taker[0],
)
elif queryset_pretaker.exists():
return (
False,
new_error(1002),
queryset_pretaker[0].order,
)
# Edge case when the user is in an order that is failing payment and he is the buyer
queryset = Order.objects.filter(
Q(maker=user) | Q(taker=user),
status__in=[Order.Status.FAI, Order.Status.PAY],
)
if queryset.exists():
order = queryset[0]
if cls.is_buyer(order, user):
return (
False,
new_error(1003),
order,
)
return True, None, None
@classmethod
def validate_order_size(cls, order):
"""Validates if order size in Sats is within limits at t0"""
if not order.has_range:
if order.t0_satoshis > MAX_ORDER_SIZE:
return False, new_error(
1004,
{
"order_amount": order.t0_satoshis,
"max_order_size": MAX_ORDER_SIZE,
},
)
if order.t0_satoshis < MIN_ORDER_SIZE:
return False, new_error(
1005,
{
"order_amount": order.t0_satoshis,
"min_order_size": MIN_ORDER_SIZE,
},
)
elif order.has_range:
min_sats = cls.calc_sats(
order.min_amount, order.currency.exchange_rate, order.premium
)
max_sats = cls.calc_sats(
order.max_amount, order.currency.exchange_rate, order.premium
)
if min_sats > max_sats / 1.5:
return False, new_error(1006)
elif max_sats > MAX_ORDER_SIZE:
return False, new_error(
1007, {"max_sats": int(max_sats), "max_order_size": MAX_ORDER_SIZE}
)
elif min_sats < MIN_ORDER_SIZE:
return False, new_error(
1008, {"min_sats": int(min_sats), "min_order_size": MIN_ORDER_SIZE}
)
elif min_sats < max_sats / 15:
return False, new_error(1009)
return True, None
@classmethod
def validate_location(cls, order) -> bool:
if not (order.latitude or order.longitude):
return True, None
country = location_country(order.longitude, order.latitude)
if country in GEOBLOCKED_COUNTRIES:
return False, new_error(1010, {"country": country})
else:
return True, None
def validate_amount_within_range(order, amount):
if amount > float(order.max_amount) or amount < float(order.min_amount):
return False, new_error(1011)
return True, None
def user_activity_status(last_seen):
if last_seen > (timezone.now() - timedelta(minutes=2)):
return "Active"
elif last_seen > (timezone.now() - timedelta(minutes=10)):
return "Seen recently"
else:
return "Inactive"
@classmethod
def take(cls, order, user, amount=None):
is_penalized, time_out = cls.is_penalized(user)
take_order = TakeOrder.objects.filter(
taker=user, order=order, expires_at__gt=timezone.now()
)
if is_penalized:
return False, new_error(1012, {"time_out": time_out})
elif take_order.exists():
order.log(
f"Order already Pre-Taken by Robot({user.robot.id},{user.username}) for {order.amount} fiat units"
)
return True, None
else:
take_order = TakeOrder.objects.create(
taker=user,
order=order,
expires_at=timezone.now()
+ timedelta(seconds=order.t_to_expire(Order.Status.TAK)),
)
if order.has_range:
take_order.amount = amount
else:
take_order.amount = order.amount
take_order.save(update_fields=["amount"])
order.log(
f"Pre-Taken by Robot({user.robot.id},{user.username}) for {order.amount} fiat units"
)
return True, None
def is_buyer(order, user):
is_maker = order.maker == user
is_taker = order.taker == user
is_pretaker = TakeOrder.objects.filter(
taker=user, order=order, expires_at__gt=timezone.now()
).exists()
return (is_maker and order.type == Order.Types.BUY) or (
(is_pretaker or is_taker) and order.type == Order.Types.SELL
)
def is_seller(order, user):
is_maker = order.maker == user
is_taker = order.taker == user
is_pretaker = TakeOrder.objects.filter(
taker=user, order=order, expires_at__gt=timezone.now()
).exists()
return (is_maker and order.type == Order.Types.SELL) or (
(is_pretaker or is_taker) and order.type == Order.Types.BUY
)
def calc_sats(amount, exchange_rate, premium):
exchange_rate = float(exchange_rate)
premium_rate = exchange_rate * (1 + float(premium) / 100)
return (float(amount) / premium_rate) * 100 * 1000 * 1000
@classmethod
def satoshis_now(cls, order, take_amount=None):
"""checks trade amount in sats"""
if order.is_explicit:
satoshis_now = order.satoshis
else:
if take_amount is not None:
amount = take_amount
else:
amount = order.amount if order.amount is not None else order.max_amount
satoshis_now = cls.calc_sats(
amount, order.currency.exchange_rate, order.premium
)
return int(satoshis_now)
def price_and_premium_now(order):
"""computes order price and premium with current rates"""
exchange_rate = float(order.currency.exchange_rate)
if not order.is_explicit:
premium = order.premium
price = exchange_rate * (1 + float(premium) / 100)
else:
amount = order.amount if not order.has_range else order.max_amount
order_rate = float(amount) / (float(order.satoshis) / 100_000_000)
premium = order_rate / exchange_rate - 1
premium = int(premium * 10_000) / 100 # 2 decimals left
price = order_rate
significant_digits = 5
price = round(
price, significant_digits - int(math.floor(math.log10(abs(price)))) - 1
)
return price, premium
@classmethod
def take_order_expires(cls, take_order):
if take_order.expires_at > timezone.now():
take_order.expires_at = timezone.now()
take_order.save(update_fields=["expires_at"])
cls.cancel_bond(take_order.taker_bond)
@classmethod
def order_expires(cls, order):
"""General cases when time runs out."""
# Do not change order status if an order in any with
# any of these status is sent to expire here
does_not_expire = [
Order.Status.UCA,
Order.Status.EXP,
Order.Status.TLD,
Order.Status.DIS,
Order.Status.CCA,
Order.Status.PAY,
Order.Status.SUC,
Order.Status.FAI,
Order.Status.MLD,
]
# in any case, if order is_swap and there is an onchain_payment, cancel it.
if order.status not in does_not_expire:
cls.cancel_onchain_payment(order)
if order.status in does_not_expire:
return False
elif order.status == Order.Status.WFB:
order.update_status(Order.Status.EXP)
order.expiry_reason = Order.ExpiryReasons.NMBOND
cls.cancel_bond(order.maker_bond)
order.save(update_fields=["expiry_reason"])
order.log("Order expired while waiting for maker bond")
order.log("Maker bond was cancelled")
return True
elif order.status in [Order.Status.PUB, Order.Status.PAU]:
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):
cls.take_order_expires(take_order)
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")
order.log("Maker bond was **unlocked**")
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
down or there was a bug. Still bonds must be charged
to avoid service DDOS."""
cls.settle_bond(order.maker_bond)
cls.settle_bond(order.taker_bond)
cls.cancel_escrow(order)
order.update_status(Order.Status.EXP)
order.expiry_reason = Order.ExpiryReasons.NESINV
order.save(update_fields=["expiry_reason"])
order.log(
"Order expired while waiting for both buyer invoice and seller escrow"
)
order.log("Maker bond was **settled**")
order.log("Taker bond was **settled**")
return True
elif order.status == Order.Status.WFE:
maker_is_seller = cls.is_seller(order, order.maker)
# If maker is seller, settle the bond and order goes to expired
if maker_is_seller:
cls.settle_bond(order.maker_bond)
cls.return_bond(order.taker_bond)
# If seller is offline the escrow LNpayment does not exist
try:
cls.cancel_escrow(order)
except Exception:
pass
order.update_status(Order.Status.EXP)
order.expiry_reason = Order.ExpiryReasons.NESCRO
order.save(update_fields=["expiry_reason"])
# Reward taker with part of the maker bond
cls.add_slashed_rewards(order, order.maker_bond, order.taker_bond)
order.log("Order expired while waiting for escrow of the maker/seller")
order.log("Maker bond was **settled**")
order.log("Taker bond was **unlocked**")
return True
# If maker is buyer, settle the taker's bond order goes back to public
else:
cls.settle_bond(order.taker_bond)
# If seller is offline the escrow LNpayment does not even exist
try:
cls.cancel_escrow(order)
except Exception:
pass
taker_bond = order.taker_bond
cls.publish_order(order)
send_notification.delay(order_id=order.id, message="order_published")
# Reward maker with part of the taker bond
cls.add_slashed_rewards(order, taker_bond, order.maker_bond)
order.log("Order expired while waiting for escrow of the taker/seller")
order.log("Taker bond was **settled**")
return True
elif order.status == Order.Status.WFI:
# The trade could happen without a buyer invoice. However, this user
# is likely AFK; will probably desert the contract as well.
maker_is_buyer = cls.is_buyer(order, order.maker)
# If maker is buyer, settle the bond and order goes to expired
if maker_is_buyer:
cls.settle_bond(order.maker_bond)
cls.return_bond(order.taker_bond)
cls.return_escrow(order)
order.update_status(Order.Status.EXP)
order.expiry_reason = Order.ExpiryReasons.NINVOI
order.save(update_fields=["expiry_reason"])
# Reward taker with part of the maker bond
cls.add_slashed_rewards(order, order.maker_bond, order.taker_bond)
order.log("Order expired while waiting for invoice of the maker/buyer")
order.log("Maker bond was **settled**")
order.log("Taker bond was **unlocked**")
return True
# If maker is seller settle the taker's bond, order goes back to public
else:
cls.settle_bond(order.taker_bond)
cls.return_escrow(order)
taker_bond = order.taker_bond
cls.publish_order(order)
send_notification.delay(order_id=order.id, message="order_published")
# Reward maker with part of the taker bond
cls.add_slashed_rewards(order, taker_bond, order.maker_bond)
order.log("Order expired while waiting for invoice of the taker/buyer")
order.log("Taker bond was **settled**")
return True
elif order.status in [Order.Status.CHA, Order.Status.FSE]:
# Another weird case. The time to confirm 'fiat sent or received' expired. Yet no dispute
# was opened. Hint: a seller-scammer could persuade a buyer to not click "fiat
# sent", we assume this is a dispute case by default.
cls.open_dispute(order)
order.log(
"Order expired during chat and a dispute was opened automatically"
)
return True
@classmethod
def kick_taker(cls, take_order):
"""The taker did not lock the taker_bond. Now he has to go"""
cls.take_order_expires(take_order)
# Add a time out to the taker
if take_order.taker:
robot = take_order.taker.robot
robot.penalty_expiration = timezone.now() + timedelta(
seconds=PENALTY_TIMEOUT
)
robot.save(update_fields=["penalty_expiration"])
take_order.order.log("Taker was kicked out of the order")
return True
@classmethod
def automatic_dispute_resolution(cls, order):
"""Simple case where a dispute can be solved with a
priori knowledge. For example, a dispute that opens
at expiration on an order where one of the participants
never sent a message on the chat and never marked 'fiat
sent'. By solving the dispute automatically before
flagging it as dispute, we avoid having to settle the
bonds"""
# If fiat has been marked as sent (or was sent and then reverted),
# automatic dispute resolution is not possible.
if order.is_fiat_sent or order.reverted_fiat_sent:
return False
# If the order has not entered dispute due to time expire
# (a user triggered it), automatic dispute resolution is
# not possible.
if order.expires_at >= timezone.now():
return False
num_messages_taker = len(
Message.objects.filter(order=order, sender=order.taker)
)
num_messages_maker = len(
Message.objects.filter(order=order, sender=order.maker)
)
if num_messages_maker == num_messages_taker == 0:
cls.return_escrow(order)
cls.settle_bond(order.maker_bond)
cls.settle_bond(order.taker_bond)
order.update_status(Order.Status.DIS)
order.log("Maker bond was **settled**")
order.log("Taker bond was **settled**")
order.log(
"No robot wrote in the chat, the dispute cannot be solved automatically"
)
elif num_messages_maker == 0:
cls.return_escrow(order)
cls.settle_bond(order.maker_bond)
cls.return_bond(order.taker_bond)
order.update_status(Order.Status.MLD)
cls.add_slashed_rewards(order, order.maker_bond, order.taker_bond)
order.log("Maker bond was **settled**")
order.log("Taker bond was **unlocked**")
order.log(
"**The dispute was solved automatically:** 'Maker lost dispute', the maker did not write in the chat"
)
elif num_messages_taker == 0:
cls.return_escrow(order)
cls.settle_bond(order.taker_bond)
cls.return_bond(order.maker_bond)
order.update_status(Order.Status.TLD)
cls.add_slashed_rewards(order, order.taker_bond, order.maker_bond)
order.log("Maker bond was **unlocked**")
order.log("Taker bond was **settled**")
order.log(
"**The dispute was solved automatically:** 'Taker lost dispute', the maker did not write in the chat"
)
else:
return False
order.is_disputed = True
order.expires_at = timezone.now() + timedelta(
seconds=order.t_to_expire(Order.Status.DIS)
)
order.save(update_fields=["is_disputed", "expires_at"])
send_notification.delay(order_id=order.id, message="dispute_opened")
return True
@classmethod
def open_dispute(cls, order, user=None):
# Always settle escrow and bonds during a dispute. Disputes
# can take long to resolve, it might trigger force closure
# for unresolved HTLCs) Dispute winner will have to submit a
# new invoice for value of escrow + bond.
valid_status_open_dispute = [
Order.Status.CHA,
Order.Status.FSE,
]
if order.status not in valid_status_open_dispute:
return False, new_error(1013)
if order.expires_at and timezone.now() < order.expires_at - timedelta(hours=18):
return False, new_error(1054)
automatically_solved = cls.automatic_dispute_resolution(order)
if automatically_solved:
return True, None
if not order.trade_escrow.status == LNPayment.Status.SETLED:
cls.settle_escrow(order)
cls.settle_bond(order.maker_bond)
cls.settle_bond(order.taker_bond)
order.is_disputed = True
order.update_status(Order.Status.DIS)
order.expires_at = timezone.now() + timedelta(
seconds=order.t_to_expire(Order.Status.DIS)
)
order.save(update_fields=["is_disputed", "expires_at"])
# User could be None if a dispute is open automatically due to time expiration.
if user is not None:
robot = user.robot
robot.num_disputes = robot.num_disputes + 1
if robot.orders_disputes_started is None:
robot.orders_disputes_started = str(order.id)
else:
disputes = list(robot.orders_disputes_started)
disputes.append(str(order.id))
robot.orders_disputes_started = disputes
robot.save(update_fields=["num_disputes", "orders_disputes_started"])
send_notification.delay(order_id=order.id, message="dispute_opened")
order.log(
f"Dispute was opened {f'by Robot({user.robot.id},{user.username})' if user else ''}"
)
order.log("Maker bond was **settled**")
order.log("Taker bond was **settled**")
return True, None
def dispute_statement(order, user, statement):
"""Updates the dispute statements"""
if not order.status == Order.Status.DIS:
return False, new_error(1014)
if len(statement) > 50_000:
return False, new_error(2000)
if len(statement) < 100:
return False, new_error(2001)
if order.maker == user:
order.maker_statement = statement
order.save(update_fields=["maker_statement"])
else:
order.taker_statement = statement
order.save(update_fields=["taker_statement"])
# If both statements are in, move status to wait for dispute resolution
if order.maker_statement not in [None, ""] and order.taker_statement not in [
None,
"",
]:
order.update_status(Order.Status.WFR)
order.expires_at = timezone.now() + timedelta(
seconds=order.t_to_expire(Order.Status.WFR)
)
order.save(update_fields=["status", "expires_at"])
order.log(
f"Dispute statement submitted by Robot({user.robot.id},{user.username}) with length of {len(statement)} chars"
)
return True, None
def compute_swap_fee_rate(balance):
shape = str(config("SWAP_FEE_SHAPE"))
if shape == "linear":
MIN_SWAP_FEE = config("MIN_SWAP_FEE", cast=float, default=0.01)
MIN_POINT = float(config("MIN_POINT"))
MAX_SWAP_FEE = float(config("MAX_SWAP_FEE"))
MAX_POINT = float(config("MAX_POINT"))
if float(balance.onchain_fraction) > MIN_POINT:
swap_fee_rate = MIN_SWAP_FEE
else:
slope = (MAX_SWAP_FEE - MIN_SWAP_FEE) / (MAX_POINT - MIN_POINT)
swap_fee_rate = (
slope * (balance.onchain_fraction - MAX_POINT) + MAX_SWAP_FEE
)
elif shape == "exponential":
MIN_SWAP_FEE = config("MIN_SWAP_FEE", cast=float, default=0.01)
MAX_SWAP_FEE = float(config("MAX_SWAP_FEE"))
SWAP_LAMBDA = float(config("SWAP_LAMBDA"))
swap_fee_rate = MIN_SWAP_FEE + (MAX_SWAP_FEE - MIN_SWAP_FEE) * math.exp(
-SWAP_LAMBDA * float(balance.onchain_fraction)
)
return swap_fee_rate * 100
@classmethod
def create_onchain_payment(cls, order, user, preliminary_amount):
"""
Creates an empty OnchainPayment for order.payout_tx.
It sets the fees to be applied to this order if onchain Swap is used.
If the user submits a LN invoice instead. The returned OnchainPayment goes unused.
"""
# Make sure no invoice payout is attached to order
order.payout = None
# Create onchain_payment
onchain_payment = OnchainPayment.objects.create(receiver=user)
# Compute a safer available onchain liquidity: (confirmed_utxos - reserve - pending_outgoing_txs))
# Accounts for already committed outgoing TX for previous users.
confirmed = onchain_payment.balance.onchain_confirmed
# We assume a reserve of 300K Sats (3 times higher than LND's default anchor reserve)
reserve = 300_000
pending_txs = OnchainPayment.objects.filter(
status__in=[OnchainPayment.Status.VALID, OnchainPayment.Status.QUEUE]
).aggregate(Sum("num_satoshis"))["num_satoshis__sum"]
if pending_txs is None:
pending_txs = 0
available_onchain = confirmed - reserve - pending_txs
if (
preliminary_amount > available_onchain
): # Not enough onchain balance to commit for this swap.
return False
suggested_mining_fee_rate = get_minning_fee("suggested", preliminary_amount)
# Hardcap mining fee suggested at 1000 sats/vbyte
if suggested_mining_fee_rate > 1000:
suggested_mining_fee_rate = 1000
onchain_payment.suggested_mining_fee_rate = max(2.05, suggested_mining_fee_rate)
onchain_payment.swap_fee_rate = cls.compute_swap_fee_rate(
onchain_payment.balance
)
onchain_payment.save()
order.payout_tx = onchain_payment
order.save(update_fields=["payout_tx"])
order.log(
f"Empty OnchainPayment({order.payout_tx.id},{order.payout_tx}) was created. Available onchain balance is {available_onchain} Sats"
)
return True
@classmethod
def payout_amount(cls, order, user):
"""Computes buyer invoice amount. Uses order.last_satoshis,
that is the final trade amount set at Taker Bond time
Adds context for onchain swap.
"""
if not cls.is_buyer(order, user):
return False, None
if user == order.maker:
fee_fraction = FEE * MAKER_FEE_SPLIT
elif user == order.taker:
fee_fraction = FEE * (1 - MAKER_FEE_SPLIT)
fee_sats = order.last_satoshis * fee_fraction
context = {}
# context necessary for the user to submit a LN invoice
context["invoice_amount"] = round(
order.last_satoshis - fee_sats
) # Trading fee to buyer is charged here.
# context necessary for the user to submit an onchain address
MIN_SWAP_AMOUNT = config("MIN_SWAP_AMOUNT", cast=int, default=20_000)
MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500_000)
if context["invoice_amount"] < MIN_SWAP_AMOUNT:
context["swap_allowed"] = False
context["swap_failure_reason"] = (
f"Order amount is smaller than the minimum swap available of {MIN_SWAP_AMOUNT} Sats"
)
order.log(
f"Onchain payment option was not offered: amount is smaller than the minimum swap available of {MIN_SWAP_AMOUNT} Sats",
level="WARN",
)
return True, context
elif context["invoice_amount"] > MAX_SWAP_AMOUNT:
context["swap_allowed"] = False
context["swap_failure_reason"] = (
f"Order amount is bigger than the maximum swap available of {MAX_SWAP_AMOUNT} Sats"
)
order.log(
f"Onchain payment option was not offered: amount is bigger than the maximum swap available of {MAX_SWAP_AMOUNT} Sats",
level="WARN",
)
return True, context
if config("DISABLE_ONCHAIN", cast=bool, default=True):
context["swap_allowed"] = False
context["swap_failure_reason"] = "On-the-fly submarine swaps are disabled"
order.log(
"Onchain payment option was not offered: on-the-fly submarine swaps are disabled"
)
return True, context
if order.payout_tx is None:
# Creates the OnchainPayment object and checks node balance
valid = cls.create_onchain_payment(
order, user, preliminary_amount=context["invoice_amount"]
)
if valid:
order.log(
f"Suggested mining fee is {order.payout_tx.suggested_mining_fee_rate} Sats/vbyte, the swap fee rate is {order.payout_tx.swap_fee_rate}%"
)
else:
context["swap_allowed"] = False
context["swap_failure_reason"] = (
"Not enough onchain liquidity available to offer a swap"
)
order.log(
"Onchain payment option was not offered: onchain liquidity available to offer a swap",
level="WARN",
)
return True, context
context["swap_allowed"] = True
context["suggested_mining_fee_rate"] = float(
order.payout_tx.suggested_mining_fee_rate
)
context["swap_fee_rate"] = order.payout_tx.swap_fee_rate
return True, context
@classmethod
def escrow_amount(cls, order, user):
"""Computes escrow invoice amount. Uses order.last_satoshis,
that is the final trade amount set at Taker Bond time"""
if user == order.maker:
fee_fraction = FEE * MAKER_FEE_SPLIT
elif user == order.taker:
fee_fraction = FEE * (1 - MAKER_FEE_SPLIT)
fee_sats = order.last_satoshis * fee_fraction
if cls.is_seller(order, user):
escrow_amount = round(
order.last_satoshis + fee_sats
) # Trading fee to seller is charged here.
return True, {"escrow_amount": escrow_amount}
@classmethod
def update_address(cls, order, user, address, mining_fee_rate):
# Empty address?
if not address:
return False, new_error(4000)
# only the buyer can post a buyer address
if not cls.is_buyer(order, user):
return False, new_error(1015)
# not the right time to submit
if not (
order.taker_bond.status
== order.maker_bond.status
== LNPayment.Status.LOCKED
) or order.status not in [Order.Status.WFI, Order.Status.WF2]:
order.log(
f"Robot({user.robot.id},{user.username}) attempted to submit an address while the order was in status {order.status}",
level="ERROR",
)
return False, new_error(1016)
# not a valid address
valid, context = validate_onchain_address(address)
if not valid:
order.log(
format_html("The address {address} is not valid", address=address),
level="WARN",
)
return False, context
num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"]
if mining_fee_rate:
# not a valid mining fee
min_mining_fee_rate = get_minning_fee("minimum", num_satoshis)
min_mining_fee_rate = max(2, min_mining_fee_rate)
if float(mining_fee_rate) < min_mining_fee_rate:
order.log(
f"The onchain fee {float(mining_fee_rate)} Sats/vbytes proposed by Robot({user.robot.id},{user.username}) is less than the current minimum mining fee {min_mining_fee_rate} Sats",
level="WARN",
)
return False, new_error(
4001, {"min_mining_fee_rate": min_mining_fee_rate}
)
elif float(mining_fee_rate) > 500:
order.log(
f"The onchain fee {float(mining_fee_rate)} Sats/vbytes proposed by Robot({user.robot.id},{user.username}) is higher than the absolute maximum mining fee 500 Sats",
level="WARN",
)
return False, new_error(4002)
order.payout_tx.mining_fee_rate = float(mining_fee_rate)
# If not mining fee provider use backend's suggested fee rate
else:
order.payout_tx.mining_fee_rate = order.payout_tx.suggested_mining_fee_rate
tx = order.payout_tx
tx.address = address
tx.mining_fee_sats = int(tx.mining_fee_rate * 280)
tx.num_satoshis = num_satoshis
tx.sent_satoshis = int(
float(tx.num_satoshis)
- float(tx.num_satoshis) * float(tx.swap_fee_rate) / 100
- float(tx.mining_fee_sats)
)
if float(tx.sent_satoshis) < 20_000:
order.log(
f"The onchain Sats to be sent ({float(tx.sent_satoshis)}) are below the dust limit of 20,000 Sats",
level="WARN",
)
return False, new_error(4003)
tx.status = OnchainPayment.Status.VALID
tx.save()
order.is_swap = True
order.save(update_fields=["is_swap"])
order.log(
f"Robot({user.robot.id},{user.username}) added an onchain address OnchainPayment({tx.id},{address[:6]}...{address[-4:]}) as payout method. Amount to be sent is {tx.sent_satoshis} Sats, mining fee is {tx.mining_fee_sats} Sats"
)
cls.move_state_updated_payout_method(order)
return True, None
@classmethod
def update_invoice(cls, order, user, invoice, routing_budget_ppm):
# Empty invoice?
if not invoice:
order.log(
f"Robot({user.robot.id},{user.username}) submitted an empty invoice",
level="WARN",
)
return False, new_error(3000)
# only the buyer can post a buyer invoice
if not cls.is_buyer(order, user):
return False, new_error(1017)
if not order.taker_bond:
return False, new_error(1018)
if (
not (
order.taker_bond.status
== order.maker_bond.status
== LNPayment.Status.LOCKED
)
and not order.status == Order.Status.FAI
):
return False, new_error(1019)
if order.status == Order.Status.FAI:
if order.payout.status != LNPayment.Status.EXPIRE:
return False, new_error(3001)
if order.status not in (Order.Status.WF2, Order.Status.WFI, Order.Status.FAI):
return False, new_error(3001)
if order.payout and order.payout.status == LNPayment.Status.FLIGHT:
return False, new_error(3001)
# cancel onchain_payout if existing
cls.cancel_onchain_payment(order)
num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"]
routing_budget_sats = float(num_satoshis) * (
float(routing_budget_ppm) / 1_000_000
)
num_satoshis = int(num_satoshis - routing_budget_sats)
payout = LNNode.validate_ln_invoice(invoice, num_satoshis, routing_budget_ppm)
if not payout["valid"]:
return False, payout["context"]
if order.payout:
if order.payout.payment_hash == payout["payment_hash"]:
return False, new_error(3002)
order.payout = LNPayment.objects.create(
concept=LNPayment.Concepts.PAYBUYER,
type=LNPayment.Types.NORM,
sender=User.objects.get(username=ESCROW_USERNAME),
receiver=user,
routing_budget_ppm=routing_budget_ppm,
routing_budget_sats=routing_budget_sats,
invoice=invoice,
status=LNPayment.Status.VALIDI,
num_satoshis=num_satoshis,
description=payout["description"],
payment_hash=payout["payment_hash"],
created_at=payout["created_at"],
expires_at=payout["expires_at"],
)
order.is_swap = False
order.save(update_fields=["payout", "is_swap"])
order.log(
f"Robot({user.robot.id},{user.username}) added the invoice LNPayment({order.payout.payment_hash},{order.payout.payment_hash}) as payout method. Amount to be sent is {order.payout.num_satoshis} Sats, routing budget is {order.payout.routing_budget_sats} Sats ({order.payout.routing_budget_ppm}ppm)"
)
cls.move_state_updated_payout_method(order)
return True, None
@classmethod
def move_state_updated_payout_method(cls, order):
# If the order status is 'Waiting for invoice'. Move forward to 'chat'
if order.status == Order.Status.WFI:
order.update_status(Order.Status.CHA)
order.expires_at = timezone.now() + timedelta(
seconds=order.t_to_expire(Order.Status.CHA)
)
send_notification.delay(order_id=order.id, message="fiat_exchange_starts")
# If the order status is 'Waiting for both'. Move forward to 'waiting for escrow'
elif order.status == Order.Status.WF2:
# If the escrow does not exist, or is not locked move to WFE.
if order.trade_escrow is None:
order.update_status(Order.Status.WFE)
# If the escrow is locked move to Chat.
elif order.trade_escrow.status == LNPayment.Status.LOCKED:
order.update_status(Order.Status.CHA)
order.expires_at = timezone.now() + timedelta(
seconds=order.t_to_expire(Order.Status.CHA)
)
send_notification.delay(
order_id=order.id, message="fiat_exchange_starts"
)
else:
order.update_status(Order.Status.WFE)
# If the order status is 'Failed Routing'. Retry payment.
elif LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash):
if order.transition_status(
Order.Status.PAY, from_statuses=[Order.Status.FAI]
):
order.payout.status = LNPayment.Status.FLIGHT
order.payout.routing_attempts = 0
order.payout.save(update_fields=["status", "routing_attempts"])
order.save(update_fields=["expires_at"])
return True