diff --git a/.env-sample b/.env-sample index 64b12a5a..3d1fa601 100644 --- a/.env-sample +++ b/.env-sample @@ -139,7 +139,6 @@ EXP_TAKER_BOND_INVOICE = 200 # Proportional routing fee limit (fraction of total payout: % / 100) PROPORTIONAL_ROUTING_FEE_LIMIT = 0.001 # Base flat limit fee for routing in Sats (used only when proportional is lower than this) -MIN_FLAT_ROUTING_FEE_LIMIT = 10 MIN_FLAT_ROUTING_FEE_LIMIT_REWARD = 2 # Routing timeouts REWARDS_TIMEOUT_SECONDS = 30 diff --git a/api/lightning/cln.py b/api/lightning/cln.py index 2bf68dc4..d1f544bf 100755 --- a/api/lightning/cln.py +++ b/api/lightning/cln.py @@ -501,7 +501,7 @@ class CLNNode: * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), ) - ) # 200 ppm or 10 sats + ) # 1000 ppm or 2 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 943f489f..e43e11b7 100644 --- a/api/lightning/lnd.py +++ b/api/lightning/lnd.py @@ -472,7 +472,7 @@ class LNDNode: * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), ) - ) # 200 ppm or 10 sats + ) # 1000 ppm or 2 sats timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) request = router_pb2.SendPaymentRequest( payment_request=lnpayment.invoice, diff --git a/api/logics.py b/api/logics.py index e93879d8..b3a835ec 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1886,7 +1886,7 @@ class Logics: return @classmethod - def withdraw_rewards(cls, user, invoice): + def withdraw_rewards(cls, user, invoice, routing_budget_ppm): # only a user with positive withdraw balance can use this if user.robot.earned_rewards < 1: @@ -1894,14 +1894,22 @@ class Logics: num_satoshis = user.robot.earned_rewards - routing_budget_sats = int( - max( - num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), - float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), + if routing_budget_ppm is not None and routing_budget_ppm is not False: + routing_budget_sats = float(num_satoshis) * ( + float(routing_budget_ppm) / 1_000_000 ) - ) # 1000 ppm or 10 sats + 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 = (routing_budget_sats / float(num_satoshis)) * 1_000_000 reward_payout = LNNode.validate_ln_invoice( invoice, num_satoshis, routing_budget_ppm ) diff --git a/api/serializers.py b/api/serializers.py index fd81e737..dc10ac25 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -680,6 +680,14 @@ class ClaimRewardSerializer(serializers.Serializer): default=None, help_text="A valid LN invoice with the reward amount to withdraw", ) + routing_budget_ppm = serializers.IntegerField( + default=0, + min_value=Decimal(0), + max_value=100_001, + allow_null=True, + required=False, + help_text="Max budget to allocate for routing in PPM", + ) class PriceSerializer(serializers.Serializer): diff --git a/api/views.py b/api/views.py index bc6570a3..bf11159b 100644 --- a/api/views.py +++ b/api/views.py @@ -904,6 +904,7 @@ class RewardView(CreateAPIView): return Response(status=status.HTTP_400_BAD_REQUEST) pgp_invoice = serializer.data.get("invoice") + routing_budget_ppm = serializer.data.get("routing_budget_ppm", None) valid_signature, invoice = verify_signed_message( request.user.robot.public_key, pgp_invoice @@ -915,7 +916,7 @@ class RewardView(CreateAPIView): status.HTTP_400_BAD_REQUEST, ) - valid, context = Logics.withdraw_rewards(request.user, invoice) + valid, context = Logics.withdraw_rewards(request.user, invoice, routing_budget_ppm) if not valid: context["successful_withdrawal"] = False diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index 912588e5..6a56e2d9 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -1732,6 +1732,45 @@ class TradeTest(BaseAPITestCase): self.assertResponse(response) self.assertIsInstance(response.json()["earned_rewards"], int) + # Submit reward invoice + path = reverse("reward") + invoice = add_invoice("robot", response.json()["earned_rewards"]) + signed_payout_invoice = sign_message( + invoice, + passphrase_path=f"tests/robots/{trade.taker_index}/token", + private_key_path=f"tests/robots/{trade.taker_index}/enc_priv_key", + ) + body = { + "invoice": signed_payout_invoice + } + + response = self.client.post(path, body, **taker_headers) + + self.assertEqual(response.status_code, 200) + self.assertResponse(response) + self.assertTrue(response.json()["successful_withdrawal"]) + + def test_withdraw_reward_after_unilateral_cancel_routing_budget(self): + """ + Tests withdraw rewards specifying routing_budget_ppm as taker after maker + cancels order unilaterally + """ + trade = Trade(self.client) + trade.publish_order() + trade.take_order() + trade.take_order_third() + trade.lock_taker_bond() + trade.cancel_order(trade.maker_index) + + # Fetch amount of rewards for taker + path = reverse("robot") + taker_headers = trade.get_robot_auth(trade.taker_index) + response = self.client.get(path, **taker_headers) + + self.assertEqual(response.status_code, 200) + self.assertResponse(response) + self.assertIsInstance(response.json()["earned_rewards"], int) + # Submit reward invoice path = reverse("reward") invoice = add_invoice("robot", response.json()["earned_rewards"]) @@ -1742,6 +1781,7 @@ class TradeTest(BaseAPITestCase): ) body = { "invoice": signed_payout_invoice, + "routing_budget_ppm": 0 } response = self.client.post(path, body, **taker_headers)