From 7e7b24dc1b2d520612cb9d440ed4caadc6e34f1e Mon Sep 17 00:00:00 2001 From: keshav0479 Date: Sun, 26 Apr 2026 13:20:40 +0530 Subject: [PATCH] fix: apply reward routing budget to payouts --- api/lightning/cln.py | 8 +- api/lightning/lnd.py | 10 +- api/logics.py | 17 ++-- api/serializers.py | 2 +- api/tests/test_logics.py | 152 ++++++++++++++++++++++++++++ docs/assets/schemas/api-latest.yaml | 1 - 6 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 api/tests/test_logics.py diff --git a/api/lightning/cln.py b/api/lightning/cln.py index b005d4ece..d6f3122d1 100755 --- a/api/lightning/cln.py +++ b/api/lightning/cln.py @@ -494,13 +494,7 @@ def pay_invoice(cls, lnpayment): """Sends sats. Used for rewards payouts""" from api.models import LNPayment - fee_limit_sat = int( - max( - lnpayment.num_satoshis - * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), - float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), - ) - ) # 1000 ppm or 2 sats + fee_limit_sat = int(lnpayment.routing_budget_sats) timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) request = node_pb2.PayRequest( bolt11=lnpayment.invoice, diff --git a/api/lightning/lnd.py b/api/lightning/lnd.py index e43e11b7d..79a2a0619 100644 --- a/api/lightning/lnd.py +++ b/api/lightning/lnd.py @@ -466,13 +466,7 @@ def pay_invoice(cls, lnpayment): """Sends sats. Used for rewards payouts""" from api.models import LNPayment - fee_limit_sat = int( - max( - lnpayment.num_satoshis - * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), - float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), - ) - ) # 1000 ppm or 2 sats + fee_limit_sat = int(lnpayment.routing_budget_sats) timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) request = router_pb2.SendPaymentRequest( payment_request=lnpayment.invoice, @@ -520,7 +514,7 @@ def pay_invoice(cls, lnpayment): lnpayment.save(update_fields=["fee", "status", "preimage"]) return True, None - return False + return False, "Payment did not return a final status" @classmethod def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): diff --git a/api/logics.py b/api/logics.py index 4dee2a8d1..5f27d1a35 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1824,23 +1824,23 @@ def withdraw_rewards(cls, user, invoice, routing_budget_ppm): if user.robot.earned_rewards < 1: return False, new_error(3003) - num_satoshis = user.robot.earned_rewards + original_rewards = user.robot.earned_rewards + num_satoshis = original_rewards if routing_budget_ppm is not None and routing_budget_ppm is not False: + routing_budget_ppm = int(routing_budget_ppm) routing_budget_sats = float(num_satoshis) * ( float(routing_budget_ppm) / 1_000_000 ) num_satoshis = int(num_satoshis - routing_budget_sats) else: - # start deprecate in the future routing_budget_sats = int( max( num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), ) ) # 1000 ppm or 2 sats - routing_budget_ppm = (routing_budget_sats / float(num_satoshis)) * 1_000_000 - # end deprecate + routing_budget_ppm = 0 reward_payout = LNNode.validate_ln_invoice( invoice, num_satoshis, routing_budget_ppm @@ -1862,6 +1862,8 @@ def withdraw_rewards(cls, user, invoice, routing_budget_ppm): payment_hash=reward_payout["payment_hash"], created_at=reward_payout["created_at"], expires_at=reward_payout["expires_at"], + routing_budget_ppm=routing_budget_ppm, + routing_budget_sats=routing_budget_sats, ) # Might fail if payment_hash already exists in DB except Exception: @@ -1871,7 +1873,10 @@ def withdraw_rewards(cls, user, invoice, routing_budget_ppm): user.robot.save(update_fields=["earned_rewards"]) # Pays the invoice. - paid, failure_reason = LNNode.pay_invoice(lnpayment) + paid, failure_reason = LNNode.pay_invoice(lnpayment) or ( + False, + "Payment did not return a final status", + ) if paid: user.robot.earned_rewards = 0 user.robot.claimed_rewards += num_satoshis @@ -1880,7 +1885,7 @@ def withdraw_rewards(cls, user, invoice, routing_budget_ppm): # If fails, adds the rewards again. else: - user.robot.earned_rewards = num_satoshis + user.robot.earned_rewards = original_rewards user.robot.save(update_fields=["earned_rewards"]) return False, new_error(3005, {"failure_reason": failure_reason}) diff --git a/api/serializers.py b/api/serializers.py index 220d7bb5b..2f5e8999c 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -690,7 +690,7 @@ class ClaimRewardSerializer(serializers.Serializer): help_text="A valid LN invoice with the reward amount to withdraw", ) routing_budget_ppm = serializers.IntegerField( - default=0, + default=None, min_value=Decimal(0), max_value=100_001, allow_null=True, diff --git a/api/tests/test_logics.py b/api/tests/test_logics.py new file mode 100644 index 000000000..78c70d4c3 --- /dev/null +++ b/api/tests/test_logics.py @@ -0,0 +1,152 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone + +from api.logics import ESCROW_USERNAME, Logics +from api.models import LNPayment + + +class TestRewardWithdrawals(TestCase): + def setUp(self): + User.objects.create_user(username=ESCROW_USERNAME) + self.user = User.objects.create_user(username="rewarded-robot") + self.user.robot.earned_rewards = 10_000 + self.user.robot.save(update_fields=["earned_rewards"]) + + def reward_payout(self): + return { + "valid": True, + "context": None, + "description": "reward payout", + "payment_hash": "ab" * 32, + "created_at": timezone.now(), + "expires_at": timezone.now() + timedelta(minutes=10), + } + + @patch("api.logics.LNNode.pay_invoice") + @patch("api.logics.LNNode.validate_ln_invoice") + def test_withdraw_rewards_stores_custom_routing_budget( + self, mock_validate_ln_invoice, mock_pay_invoice + ): + mock_validate_ln_invoice.return_value = self.reward_payout() + mock_pay_invoice.return_value = (True, None) + + valid, context = Logics.withdraw_rewards(self.user, "signed-invoice", 10_000) + + self.assertTrue(valid) + self.assertIsNone(context) + mock_validate_ln_invoice.assert_called_once_with( + "signed-invoice", 9_900, 10_000 + ) + + lnpayment = mock_pay_invoice.call_args.args[0] + self.assertEqual(lnpayment.num_satoshis, 9_900) + self.assertEqual(lnpayment.routing_budget_ppm, 10_000) + self.assertEqual(float(lnpayment.routing_budget_sats), 100.0) + + saved_lnpayment = LNPayment.objects.get(payment_hash="ab" * 32) + self.assertEqual(saved_lnpayment.routing_budget_ppm, 10_000) + self.assertEqual(float(saved_lnpayment.routing_budget_sats), 100.0) + + @patch("api.logics.LNNode.pay_invoice") + @patch("api.logics.LNNode.validate_ln_invoice") + def test_withdraw_rewards_restores_original_balance_when_payment_fails( + self, mock_validate_ln_invoice, mock_pay_invoice + ): + mock_validate_ln_invoice.return_value = self.reward_payout() + mock_pay_invoice.return_value = (False, "no route") + + valid, context = Logics.withdraw_rewards(self.user, "signed-invoice", 10_000) + + self.assertFalse(valid) + self.assertEqual(context["error_code"], 3005) + self.assertEqual( + context["bad_invoice"], + "Invoice payment failure: no route", + ) + + self.user.robot.refresh_from_db() + self.assertEqual(self.user.robot.earned_rewards, 10_000) + + lnpayment = mock_pay_invoice.call_args.args[0] + self.assertEqual(lnpayment.num_satoshis, 9_900) + self.assertEqual(lnpayment.routing_budget_ppm, 10_000) + self.assertEqual(float(lnpayment.routing_budget_sats), 100.0) + + @patch("api.logics.LNNode.pay_invoice") + @patch("api.logics.LNNode.validate_ln_invoice") + def test_withdraw_rewards_restores_balance_when_payment_returns_false( + self, mock_validate_ln_invoice, mock_pay_invoice + ): + mock_validate_ln_invoice.return_value = self.reward_payout() + mock_pay_invoice.return_value = False + + valid, context = Logics.withdraw_rewards(self.user, "signed-invoice", 10_000) + + self.assertFalse(valid) + self.assertEqual(context["error_code"], 3005) + + self.user.robot.refresh_from_db() + self.assertEqual(self.user.robot.earned_rewards, 10_000) + + @patch("api.logics.config") + @patch("api.logics.LNNode.pay_invoice") + @patch("api.logics.LNNode.validate_ln_invoice") + def test_withdraw_rewards_keeps_zero_budget_for_zero_ppm( + self, mock_validate_ln_invoice, mock_pay_invoice, mock_config + ): + def config_side_effect(key): + return { + "PROPORTIONAL_ROUTING_FEE_LIMIT": 0.001, + "MIN_FLAT_ROUTING_FEE_LIMIT_REWARD": 2, + }[key] + + mock_config.side_effect = config_side_effect + mock_validate_ln_invoice.return_value = self.reward_payout() + mock_pay_invoice.return_value = (True, None) + + valid, context = Logics.withdraw_rewards(self.user, "signed-invoice", 0) + + self.assertTrue(valid) + self.assertIsNone(context) + mock_validate_ln_invoice.assert_called_once_with("signed-invoice", 10_000, 0) + + lnpayment = mock_pay_invoice.call_args.args[0] + self.assertEqual(lnpayment.num_satoshis, 10_000) + self.assertEqual(lnpayment.routing_budget_ppm, 0) + self.assertEqual(float(lnpayment.routing_budget_sats), 0.0) + + saved_lnpayment = LNPayment.objects.get(payment_hash="ab" * 32) + self.assertEqual(saved_lnpayment.routing_budget_ppm, 0) + self.assertEqual(float(saved_lnpayment.routing_budget_sats), 0.0) + mock_config.assert_not_called() + + @patch("api.logics.config") + @patch("api.logics.LNNode.pay_invoice") + @patch("api.logics.LNNode.validate_ln_invoice") + def test_withdraw_rewards_keeps_default_budget_for_legacy_none( + self, mock_validate_ln_invoice, mock_pay_invoice, mock_config + ): + def config_side_effect(key): + return { + "PROPORTIONAL_ROUTING_FEE_LIMIT": 0.001, + "MIN_FLAT_ROUTING_FEE_LIMIT_REWARD": 2, + }[key] + + mock_config.side_effect = config_side_effect + mock_validate_ln_invoice.return_value = self.reward_payout() + mock_pay_invoice.return_value = (True, None) + + valid, context = Logics.withdraw_rewards(self.user, "signed-invoice", None) + + self.assertTrue(valid) + self.assertIsNone(context) + mock_validate_ln_invoice.assert_called_once_with("signed-invoice", 10_000, 0) + + lnpayment = mock_pay_invoice.call_args.args[0] + self.assertEqual(lnpayment.num_satoshis, 10_000) + self.assertEqual(lnpayment.routing_budget_ppm, 0) + self.assertEqual(float(lnpayment.routing_budget_sats), 10.0) diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index 0356d712c..936f0a3f3 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -1163,7 +1163,6 @@ components: maximum: 100001 minimum: 0 nullable: true - default: 0 description: Max budget to allocate for routing in PPM ExpiryReasonEnum: enum: