From be292984d6c8f4f4dee8172de8d4257d808a326f Mon Sep 17 00:00:00 2001 From: Alice Coordinator Date: Sat, 30 May 2026 19:47:45 +0000 Subject: [PATCH] Fix proceeds calculation A new method `transition_status` was added, to prevent race conditions while two processes try to transition the order status at the same time. With the old implementation, if the payout takes a bit longer, the background job could start new tasks for the same payout, which would cause the order status to go from status 13 to 14 back and forth a few times. Additionally, the compute_proceeds call was moved to later in the process for LN payouts, so that the payout fee is always updated. --- api/lightning/cln.py | 11 +++++++++-- api/lightning/lnd.py | 12 +++++++++--- api/logics.py | 26 +++++++++++++++++++------- api/models/order.py | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/api/lightning/cln.py b/api/lightning/cln.py index b005d4ece..8ea348162 100755 --- a/api/lightning/cln.py +++ b/api/lightning/cln.py @@ -585,12 +585,19 @@ def watchpayment(): time.sleep(2) def handle_response(): + from api.logics import Logics + + if not order.transition_status( + Order.Status.PAY, + from_statuses=[Order.Status.PAY, Order.Status.FAI], + ): + return + try: lnpayment.status = LNPayment.Status.FLIGHT lnpayment.in_flight = True lnpayment.save(update_fields=["in_flight", "status"]) - order.update_status(Order.Status.PAY) nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.Pay(request) @@ -649,7 +656,7 @@ def handle_response(): ) lnpayment.preimage = response.payment_preimage.hex() lnpayment.save(update_fields=["status", "fee", "preimage"]) - order.update_status(Order.Status.SUC) + Logics.complete_order(order) order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.SUC) ) diff --git a/api/lightning/lnd.py b/api/lightning/lnd.py index e43e11b7d..2a298ac86 100644 --- a/api/lightning/lnd.py +++ b/api/lightning/lnd.py @@ -546,11 +546,17 @@ def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): return def handle_response(response, was_in_transit=False): + from api.logics import Logics + + if not order.transition_status( + Order.Status.PAY, + from_statuses=[Order.Status.PAY, Order.Status.FAI], + ): + return + lnpayment.status = LNPayment.Status.FLIGHT lnpayment.in_flight = True lnpayment.save(update_fields=["in_flight", "status"]) - order.update_status(Order.Status.PAY) - order.save(update_fields=["status"]) if ( response.status == lightning_pb2.Payment.PaymentStatus.UNKNOWN @@ -625,7 +631,7 @@ def handle_response(response, was_in_transit=False): lnpayment.preimage = response.payment_preimage lnpayment.save(update_fields=["status", "fee", "preimage"]) - order.update_status(Order.Status.SUC) + Logics.complete_order(order) order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.SUC) ) diff --git a/api/logics.py b/api/logics.py index 4dee2a8d1..61aabb668 100644 --- a/api/logics.py +++ b/api/logics.py @@ -967,9 +967,10 @@ def move_state_updated_payout_method(cls, order): order.update_status(Order.Status.WFE) # If the order status is 'Failed Routing'. Retry payment. - elif order.status == Order.Status.FAI: - if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash): - order.update_status(Order.Status.PAY) + 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"]) @@ -1657,7 +1658,7 @@ def pay_buyer(cls, order): order.payout_tx.status = OnchainPayment.Status.QUEUE order.payout_tx.save(update_fields=["status"]) - order.update_status(Order.Status.SUC) + cls.complete_order(order) order.contract_finalization_time = timezone.now() order.save(update_fields=["contract_finalization_time"]) @@ -1665,6 +1666,20 @@ def pay_buyer(cls, order): order.log("Paying buyer onchain address") return True + @classmethod + def complete_order(cls, order): + """ + Completes the order after the the sats are successfully paid out + and computes the coordinator revenue. + """ + if not order.transition_status( + Order.Status.SUC, from_statuses=[Order.Status.FSE, Order.Status.PAY, Order.Status.FAI] + ): + return + + # Computes coordinator trade revenue + cls.compute_proceeds(order) + @classmethod def confirm_fiat(cls, order, user): """If Order is in the CHAT states: @@ -1710,9 +1725,6 @@ def confirm_fiat(cls, order, user): # !!! KEY LINE - PAYS THE BUYER INVOICE !!! cls.pay_buyer(order) - # Computes coordinator trade revenue - cls.compute_proceeds(order) - return True, None else: diff --git a/api/models/order.py b/api/models/order.py index 612486fdd..2e95e2efe 100644 --- a/api/models/order.py +++ b/api/models/order.py @@ -363,12 +363,39 @@ def update_status(self, new_status): old_status = self.status self.status = new_status self.save(update_fields=["status"]) - self.log( - f"Order state went from {old_status}: {Order.Status(old_status).label} to {new_status}: {Order.Status(new_status).label}" - ) + + self.log_status_transition(old_status, new_status) + if new_status == Order.Status.FAI: send_notification.delay(order_id=self.id, message="lightning_failed") + def transition_status(self, new_status, from_statuses): + """ + Atomically transition the order to `new_status` only if its current + database status is one of `from_statuses`. + + Returns True if the transition happened, False otherwise. + The in-memory `self.status` is kept in sync on success. + """ + updated = Order.objects.filter( + pk=self.pk, + status__in=from_statuses, + ).update(status=new_status) + + if updated == 1: + old_status = self.status + self.status = new_status + self.log_status_transition(old_status, new_status) + return True + + return False + + def log_status_transition(self, old_status, new_status): + if old_status != new_status: + self.log( + f"Order state went from {old_status}: {Order.Status(old_status).label} to {new_status}: {Order.Status(new_status).label}" + ) + @receiver(pre_delete, sender=Order) def delete_lnpayment_at_order_deletion(sender, instance, **kwargs):