From 189b736594d58a6a4d8daefd82e7b720c1617a1d Mon Sep 17 00:00:00 2001 From: Lucas Jeffrey Date: Tue, 18 Aug 2026 22:53:00 -0300 Subject: [PATCH 1/4] Fix: prevent duplicate payment enqueuing if all celery workers are stuck This PR introduces LNPayment.Status.QUEUED to prevent a payment from being re-enqueued multiple times via follow_send_payment.delay when workers are stuck. --- api/management/commands/follow_invoices.py | 2 ++ api/models/ln_payment.py | 1 + 2 files changed, 3 insertions(+) diff --git a/api/management/commands/follow_invoices.py b/api/management/commands/follow_invoices.py index 58d953a75..5066982cf 100644 --- a/api/management/commands/follow_invoices.py +++ b/api/management/commands/follow_invoices.py @@ -184,6 +184,8 @@ def send_ln_payments(self): order.trade_escrow.status == LNPayment.Status.SETLED and order.is_swap is False ): + lnpayment.status = LNPayment.Status.QUEUED + lnpayment.save(update_fields=["status"]) follow_send_payment.delay(lnpayment.payment_hash) def send_onchain_payments(self): diff --git a/api/models/ln_payment.py b/api/models/ln_payment.py index 76a73be3a..719b57bde 100644 --- a/api/models/ln_payment.py +++ b/api/models/ln_payment.py @@ -30,6 +30,7 @@ class Status(models.IntegerChoices): FLIGHT = 7, "In flight" SUCCED = 8, "Succeeded" FAILRO = 9, "Routing failed" + QUEUED = 10, "Queued" class FailureReason(models.IntegerChoices): NOTYETF = 0, "Payment isn't failed (yet)" From 0881558e02f0c290c0a87ef98b1e939f6569be27 Mon Sep 17 00:00:00 2001 From: Lucas Jeffrey Date: Tue, 18 Aug 2026 23:34:54 -0300 Subject: [PATCH 2/4] Add database migration --- api/migrations/0058_alter_lnpayment_status.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 api/migrations/0058_alter_lnpayment_status.py diff --git a/api/migrations/0058_alter_lnpayment_status.py b/api/migrations/0058_alter_lnpayment_status.py new file mode 100644 index 000000000..48c5d1148 --- /dev/null +++ b/api/migrations/0058_alter_lnpayment_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-08-19 02:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0057_robot_webhook_enabled_alter_order_escrow_duration'), + ] + + operations = [ + migrations.AlterField( + model_name='lnpayment', + name='status', + field=models.PositiveSmallIntegerField(choices=[(0, 'Generated'), (1, 'Locked'), (2, 'Settled'), (3, 'Returned'), (4, 'Cancelled'), (5, 'Expired'), (6, 'Valid'), (7, 'In flight'), (8, 'Succeeded'), (9, 'Routing failed'), (10, 'Queued')], default=0), + ), + ] From fb9eccf35b01777462e50a3ddf7accb1be61478c Mon Sep 17 00:00:00 2001 From: Lucas Jeffrey Date: Wed, 19 Aug 2026 21:07:32 -0300 Subject: [PATCH 3/4] Add a test case that validates LNPayments are not queued twice when follow_send_payment between the two iterations --- api/tests/test_follow_invoices.py | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 api/tests/test_follow_invoices.py diff --git a/api/tests/test_follow_invoices.py b/api/tests/test_follow_invoices.py new file mode 100644 index 000000000..a927381ba --- /dev/null +++ b/api/tests/test_follow_invoices.py @@ -0,0 +1,77 @@ +from datetime import timedelta +from unittest.mock import patch +from django.test import TestCase +from django.contrib.auth.models import User +from django.utils import timezone +from api.models import LNPayment, Order +from api.management.commands.follow_invoices import Command + + +class TestFollowInvoices(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="maker", password="password") + self.escrow_user = User.objects.create_user(username="escrow", password="password") + + now = timezone.now() + # Create escrow payment with SETLED status + self.escrow_payment = LNPayment.objects.create( + payment_hash="abc123escrow", + type=LNPayment.Types.HOLD, + concept=LNPayment.Concepts.TRESCROW, + status=LNPayment.Status.SETLED, + num_satoshis=100000, + sender=self.user, + receiver=self.escrow_user, + created_at=now, + expires_at=now + timedelta(hours=1), + ) + + # Create payout payment (norm type, FLIGHT status) + self.payout_payment = LNPayment.objects.create( + payment_hash="abc123payout", + type=LNPayment.Types.NORM, + concept=LNPayment.Concepts.PAYBUYER, + status=LNPayment.Status.FLIGHT, + num_satoshis=99000, + sender=self.escrow_user, + receiver=self.user, + created_at=now, + expires_at=now + timedelta(hours=1), + ) + + # Create order linking escrow and payout + self.order = Order.objects.create( + maker=self.user, + trade_escrow=self.escrow_payment, + payout=self.payout_payment, + is_swap=False, + type=Order.Types.BUY, + expires_at=now + timedelta(hours=1), + ) + + @patch("api.management.commands.follow_invoices.follow_send_payment") + def test_send_ln_payments_queues_once(self, mock_follow_send_payment): + """ + Validates that send_ln_payments enqueues the payment once and updates + its status to QUEUED, preventing duplicate enqueuing on subsequent polls + (which would happen in main where status remained FLIGHT). + """ + command = Command() + + # First run of send_ln_payments + command.send_ln_payments() + + # Verify task was called once + mock_follow_send_payment.delay.assert_called_once_with(self.payout_payment.payment_hash) + + # Verify payment status changed to QUEUED + self.payout_payment.refresh_from_db() + self.assertEqual(self.payout_payment.status, LNPayment.Status.QUEUED) + + # Second run of send_ln_payments (simulating subsequent poll when workers are stuck) + mock_follow_send_payment.reset_mock() + command.send_ln_payments() + + # Verify task was NOT called again (only enqueued once) + mock_follow_send_payment.delay.assert_not_called() + From b40f9a6f59a9e506b2029509e5abad899d375918 Mon Sep 17 00:00:00 2001 From: Lucas Jeffrey Date: Wed, 19 Aug 2026 21:52:02 -0300 Subject: [PATCH 4/4] Make test case compatible with code in main --- api/tests/test_follow_invoices.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/tests/test_follow_invoices.py b/api/tests/test_follow_invoices.py index a927381ba..241bdec17 100644 --- a/api/tests/test_follow_invoices.py +++ b/api/tests/test_follow_invoices.py @@ -66,7 +66,11 @@ def test_send_ln_payments_queues_once(self, mock_follow_send_payment): # Verify payment status changed to QUEUED self.payout_payment.refresh_from_db() - self.assertEqual(self.payout_payment.status, LNPayment.Status.QUEUED) + if hasattr(LNPayment.Status, "QUEUED"): + self.assertEqual(self.payout_payment.status, LNPayment.Status.QUEUED) + else: + # If QUEUED status is not defined, we can skip this assertion + pass # Second run of send_ln_payments (simulating subsequent poll when workers are stuck) mock_follow_send_payment.reset_mock()