From d5c1e1e92a5da19fe27479254fef6e5eab8bf7a8 Mon Sep 17 00:00:00 2001 From: Lagrang3 Date: Mon, 30 Mar 2026 22:00:02 +0100 Subject: [PATCH 1/3] xpay: add regtest for blinded path fees Changelog-None Signed-off-by: Lagrang3 --- tests/test_xpay.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_xpay.py b/tests/test_xpay.py index 9dd07c32c041..86ff9b5e5b13 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -1072,3 +1072,36 @@ def mock_getblockhash(req): # 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_bolt12_fees(node_factory): + 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) + + # BOLT 12, direct peer + offer = l2.rpc.offer("any")["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 From 3b720305c8cf3c27c7626885d31663e3fc4dea87 Mon Sep 17 00:00:00 2001 From: Lagrang3 Date: Mon, 30 Mar 2026 22:02:57 +0100 Subject: [PATCH 2/3] injectpayment_onion: fix fees for blinded paths Changelog-Fixed: injectpayment_onion: fix fees for blinded paths, treat amount_msat as the incoming amount and not the forward amount. Signed-off-by: Lagrang3 --- contrib/msggen/msggen/schema.json | 2 +- doc/schemas/injectpaymentonion.json | 2 +- lightningd/pay.c | 22 +++++++++++----------- tests/test_pay.py | 2 +- tests/test_xpay.py | 1 - 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json index 8af25ff5f864..f23ec76887bb 100644 --- a/contrib/msggen/msggen/schema.json +++ b/contrib/msggen/msggen/schema.json @@ -16068,7 +16068,7 @@ "amount_msat": { "type": "msat", "description": [ - "The amount for the first HTLC in millisatoshis. This is also the amount which will be forwarded to the first peer (if any) as we do not charge fees on our own payments. Note: this is shown in listsendpays as `amount_sent_msat`." + "The amount in millisatoshis this node would receive from a peer before forwarding the payment to the next." ] }, "cltv_expiry": { diff --git a/doc/schemas/injectpaymentonion.json b/doc/schemas/injectpaymentonion.json index 71d7aaaadd53..b551705f30a6 100644 --- a/doc/schemas/injectpaymentonion.json +++ b/doc/schemas/injectpaymentonion.json @@ -34,7 +34,7 @@ "amount_msat": { "type": "msat", "description": [ - "The amount for the first HTLC in millisatoshis. This is also the amount which will be forwarded to the first peer (if any) as we do not charge fees on our own payments. Note: this is shown in listsendpays as `amount_sent_msat`." + "The amount in millisatoshis this node would receive from a peer before forwarding the payment to the next." ] }, "cltv_expiry": { diff --git a/lightningd/pay.c b/lightningd/pay.c index 156ecf303258..4656b50c1c21 100644 --- a/lightningd/pay.c +++ b/lightningd/pay.c @@ -1970,7 +1970,7 @@ static struct command_result *json_injectpaymentonion(struct command *cmd, register_payment_and_waiter(cmd, payment_hash, *partid, *groupid, - *destination_msat, *msat, AMOUNT_MSAT(0), + *destination_msat, payload->amt_to_forward, AMOUNT_MSAT(0), label, invstring, local_invreq_id, &shared_secret, destination); @@ -1978,7 +1978,7 @@ static struct command_result *json_injectpaymentonion(struct command *cmd, /* Mark it pending now, though htlc_set_add might * not resolve immediately */ fixme_ignore(command_still_pending(cmd)); - htlc_set_add(cmd->ld, cmd->ld->log, *msat, *payload->total_msat, + htlc_set_add(cmd->ld, cmd->ld->log, payload->amt_to_forward, *payload->total_msat, NULL, payment_hash, payload->payment_secret, selfpay_mpp_fail, selfpay_mpp_succeeded, selfpay); @@ -2014,22 +2014,22 @@ static struct command_result *json_injectpaymentonion(struct command *cmd, "Unknown peer %s", fmt_node_id(tmpctx, &nid)); - next = best_channel(cmd->ld, next_peer, *msat, NULL); + next = best_channel(cmd->ld, next_peer, payload->amt_to_forward, NULL); if (!next) return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "No available channel with peer %s", fmt_node_id(tmpctx, &nid)); } - if (amount_msat_greater(*msat, next->htlc_maximum_msat) - || amount_msat_less(*msat, next->htlc_minimum_msat)) { + if (amount_msat_greater(payload->amt_to_forward, next->htlc_maximum_msat) + || amount_msat_less(payload->amt_to_forward, next->htlc_minimum_msat)) { /* Are we in old-range grace-period? */ if (!timemono_before(time_mono(), next->old_feerate_timeout) - || amount_msat_less(*msat, next->old_htlc_minimum_msat) - || amount_msat_greater(*msat, next->old_htlc_maximum_msat)) { + || amount_msat_less(payload->amt_to_forward, next->old_htlc_minimum_msat) + || amount_msat_greater(payload->amt_to_forward, next->old_htlc_maximum_msat)) { return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "Amount %s not in htlc min/max range %s-%s", - fmt_amount_msat(tmpctx, *msat), + fmt_amount_msat(tmpctx, payload->amt_to_forward), fmt_amount_msat(tmpctx, next->htlc_minimum_msat), fmt_amount_msat(tmpctx, next->htlc_maximum_msat)); } @@ -2082,11 +2082,11 @@ static struct command_result *json_injectpaymentonion(struct command *cmd, if (command_check_only(cmd)) return command_check_done(cmd); - failmsg = send_htlc_out(tmpctx, next, *msat, + failmsg = send_htlc_out(tmpctx, next, payload->amt_to_forward, *cltv, /* If unknown, we set this equal (so accounting logs 0 fees) */ amount_msat_eq(*destination_msat, AMOUNT_MSAT(0)) - ? *msat : *destination_msat, + ? payload->amt_to_forward : *destination_msat, payment_hash, next_path_key, NULL, *partid, *groupid, serialize_onionpacket(tmpctx, rs->next), @@ -2101,7 +2101,7 @@ static struct command_result *json_injectpaymentonion(struct command *cmd, register_payment_and_waiter(cmd, payment_hash, *partid, *groupid, - *destination_msat, *msat, AMOUNT_MSAT(0), + *destination_msat, payload->amt_to_forward, AMOUNT_MSAT(0), label, invstring, local_invreq_id, &shared_secret, destination); diff --git a/tests/test_pay.py b/tests/test_pay.py index 774567888e35..0945c1b849ed 100644 --- a/tests/test_pay.py +++ b/tests/test_pay.py @@ -6366,7 +6366,7 @@ def test_injectpaymentonion_selfpay(node_factory, executor): 'payload': serialize_payload_final_tlv(333, 18, 1000, blockheight, inv5['payment_secret']).hex()}] onion1 = l1.rpc.createonion(hops=hops1, assocdata=inv5['payment_hash']) hops2 = [{'pubkey': l1.info['id'], - 'payload': serialize_payload_final_tlv(666, 18, 1000, blockheight, inv5['payment_secret']).hex()}] + 'payload': serialize_payload_final_tlv(667, 18, 1000, blockheight, inv5['payment_secret']).hex()}] onion2 = l1.rpc.createonion(hops=hops2, assocdata=inv5['payment_hash']) fut1 = executor.submit(l1.rpc.injectpaymentonion, diff --git a/tests/test_xpay.py b/tests/test_xpay.py index 86ff9b5e5b13..a3107f793210 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -1074,7 +1074,6 @@ def mock_getblockhash(req): fut.result(TIMEOUT) -@pytest.mark.xfail(strict=True) def test_bolt12_fees(node_factory): AMT_MSAT = 10000 FEES_MSAT = 5000 From f59cedbd55351fbcdc3207268d867dda786264b0 Mon Sep 17 00:00:00 2001 From: Lagrang3 Date: Tue, 31 Mar 2026 13:22:52 +0100 Subject: [PATCH 3/3] xpay: fix reported value of amount_sent_msat Changelog-None Signed-off-by: Lagrang3 --- plugins/xpay/xpay.c | 12 ++++++++++-- tests/test_xpay.py | 3 +-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c index e05e2df3f85e..e1adb71f09ad 100644 --- a/plugins/xpay/xpay.c +++ b/plugins/xpay/xpay.c @@ -343,6 +343,13 @@ send_payment_req(struct command *aux_cmd, /* For self-pay, we don't have hops. */ static struct amount_msat initial_sent(const struct attempt *attempt) +{ + if (tal_count(attempt->hops) == 0) + return attempt->delivers; + return attempt->hops[0].amount_out; +} + +static struct amount_msat inject_amount(const struct attempt *attempt) { if (tal_count(attempt->hops) == 0) return attempt->delivers; @@ -1143,7 +1150,7 @@ static struct command_result *do_inject(struct command *aux_cmd, json_add_hex_talarr(req->js, "onion", onion); json_add_sha256(req->js, "payment_hash", &attempt->payment->payment_hash); /* If no route, its the same as delivery (self-pay) */ - json_add_amount_msat(req->js, "amount_msat", initial_sent(attempt)); + json_add_amount_msat(req->js, "amount_msat", inject_amount(attempt)); json_add_u32(req->js, "cltv_expiry", initial_cltv_delta(attempt) + effective_bheight); json_add_u64(req->js, "partid", attempt->partid); json_add_u64(req->js, "groupid", attempt->payment->group_id); @@ -1458,7 +1465,8 @@ static struct command_result *getroutes_for(struct command *aux_cmd, json_array_start(req->js, "layers"); /* Add local channels */ json_add_string(req->js, NULL, "auto.localchans"); - /* We don't pay fees for ourselves */ + /* For the MCF computation we must discard the cost of routing through + * our own channels because we don't pay fees for that. */ json_add_string(req->js, NULL, "auto.sourcefree"); /* Add xpay global channel */ json_add_string(req->js, NULL, "xpay"); diff --git a/tests/test_xpay.py b/tests/test_xpay.py index a3107f793210..aae6efa809fb 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -1097,9 +1097,8 @@ def test_bolt12_fees(node_factory): 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 + assert ret["amount_sent_msat"] == AMT_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"]