Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions api/lightning/cln.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 2 additions & 8 deletions api/lightning/lnd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 11 additions & 6 deletions api/logics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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})

Expand Down
2 changes: 1 addition & 1 deletion api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
152 changes: 152 additions & 0 deletions api/tests/test_logics.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion docs/assets/schemas/api-latest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1163,7 +1163,6 @@ components:
maximum: 100001
minimum: 0
nullable: true
default: 0
description: Max budget to allocate for routing in PPM
ExpiryReasonEnum:
enum:
Expand Down
Loading