xpay: add regtest for blinded path fees
What changed, and why it matters
This commit adds a new test case that demonstrates a bug in Core Lightning's xpay feature when paying over a 'blinded path' where the payer is the first hop. The test shows that the payer incorrectly adds fees to the amount it forwards to its direct peer, even though it is effectively paying itself. The test is marked as expected to fail (xfail), meaning the bug is known but not yet fixed in this commit. It is a test-only change, not a code fix.
Treat this as a known bug requiring a follow-up fix in the xpay/blinded-path routing logic. The test should be monitored; once the underlying code is fixed, the xfail marker should be removed or converted to a normal passing test. Users relying on xpay with blinded paths where they are the entry node may overpay until the bug is fixed.
Security signals we found
Incorrect fee handling in blinded payment path routing
Overpayment to direct peer due to fee double-counting when payer is blinded-path entry point
Test-only regression commit marked expected-to-fail, indicating an active, unfixed bug
Evidence from the diff
The commit adds a regression test in tests/test_xpay.py named test_blinded_path_fees, decorated with @pytest.mark.xfail(strict=True). The test constructs a two-node network (l1, l2) where l2 issues a BOLT12 offer with l1 as the fronting/blinded-path entry node. l1 fetches the invoice and pays it via xpay. The test asserts that the HTLC amount sent to the direct peer equals AMT_MSAT (10000 msat), but the current behavior sends AMT_MSAT + FEES_MSAT (15000 msat). The test also checks that amount_sent_msat includes the fee, which is expected. The failure indicates that when the payer is the entry point to a blinded path, it erroneously includes the blinded-path fee in the outgoing HTLC to the next peer instead of only forwarding the invoice amount.
Changed components
xpay RPC/pluginBOLT12/blinded path payment handlingHTLC amount computation for first hop of blinded pathInspect captured patch +35 / −0
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index db4fde9f..2d909ccf 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -1072,3 +1072,38 @@ def test_xpay_blockheight_mismatch(node_factory, bitcoind, executor):
# Now let it catch up, and it will retry, and succeed.
l1.daemon.rpcproxy.mock_rpc('getblockhash')
fut.result(TIMEOUT)
+
+
+@pytest.mark.xfail(strict=True)
+def test_blinded_path_fees(node_factory):
+ """Test that we don't send the amount+fees to our direct peer (we should
+ only send the required amount) when the sending node is the entry point in
+ the blinded path."""
+ AMT_MSAT = 10000
+ FEES_MSAT = 5000
+ l1, l2 = node_factory.get_nodes(
+ 2, opts={"may_reconnect": True, "fee-base": FEES_MSAT, "fee-per-satoshi": 0}
+ )
+ node_factory.join_nodes([l1, l2], wait_for_announce=True)
+
+ offer = l2.rpc.offer(amount="any", fronting_nodes=[l1.info["id"]])["bolt12"]
+ b12 = l1.rpc.fetchinvoice(offer, AMT_MSAT)["invoice"]
+
+ b12_decode = l1.rpc.decode(b12)
+ assert b12_decode["invoice_amount_msat"] == AMT_MSAT
+ assert len(b12_decode["invoice_paths"]) == 1
+ assert b12_decode["invoice_paths"][0]["first_node_id"] == l1.info["id"]
+ assert b12_decode["invoice_paths"][0]["payinfo"]["fee_base_msat"] == FEES_MSAT
+ assert b12_decode["invoice_paths"][0]["payinfo"]["fee_proportional_millionths"] == 0
+
+ ret = l1.rpc.xpay(invstring=b12)
+ assert ret["failed_parts"] == 0
+ assert ret["successful_parts"] == 1
+ assert ret["amount_msat"] == AMT_MSAT
+ assert ret["amount_sent_msat"] == AMT_MSAT + FEES_MSAT
+
+ # we pay fees to ourselves
+ htlcs = l1.rpc.listhtlcs()["htlcs"]
+ assert len(htlcs) == 1
+ assert htlcs[0]["payment_hash"] == b12_decode["invoice_payment_hash"]
+ assert htlcs[0]["amount_msat"] == AMT_MSAT
Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.