xpay: process channel_update from error for the current payment.
What changed, and why it matters
This change improves the 'xpay' payment plugin in Core Lightning so that when a payment attempt fails and the error message contains a channel update, xpay uses that update only for the current payment's retry instead of applying it globally. The commit message explicitly says the old global behavior risked leaking information, and the new scoped behavior reduces that privacy risk while still helping payments succeed.
No immediate action required; this is a defensive privacy improvement. Operators and reviewers should be aware that xpay intentionally does not verify channel_update signatures in this path, relying on scoping to limit impact. If stronger assurance is desired, future work could add signature verification or rate-limiting of bias updates.
Security signals we found
Privacy improvement: channel_update from error is scoped to current payment only, avoiding global gossip leakage
Commit message states prior global application 'risks leaking information'
Signature on embedded channel_update is not verified
Channel update source/correctness is not validated beyond field parsing
Negative bias added against channel that provided the update
Evidence from the diff
The patch adds process_channel_update_from_onion_error() in plugins/xpay/xpay.c. It parses channel_update payloads from onion failure messages (temporary_channel_failure, amount_below_minimum, fee_insufficient, incorrect_cltv_expiry, expiry_too_soon), decodes the embedded channel_update, compares it to the current gossmap view, and if different applies it only to the payment’s private askrene layer via askrene-update-channel. It also adds a negative bias via askrene-bias-channel for the same layer. The update is not verified for signature or correctness, and it is not persisted globally, so it affects only retries of the current payment. Two tests are added: one verifies a temporary_channel_failure with a channel_update is processed but not persisted across payments, the other verifies fee_insufficient updates are used for retry.
Changed components
plugins/xpay/xpay.ctests/test_xpay.pyInspect captured patch +189 / −0
diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c
index 0e26a569..93f4175a 100644
--- a/plugins/xpay/xpay.c
+++ b/plugins/xpay/xpay.c
@@ -652,6 +652,120 @@ static u32 error_blockheight(const u8 *errmsg)
return height;
}
+/* Return true if this contained a channel_update which (potentially) changed something. */
+static bool process_channel_update_from_onion_error(struct command *aux_cmd,
+ struct attempt *attempt,
+ const u8 *onion_message,
+ const char *errname)
+{
+ u8 *channel_update;
+ struct amount_msat unused_msat;
+ u32 unused32;
+ secp256k1_ecdsa_signature signature;
+ struct bitcoin_blkid chain_hash;
+ struct short_channel_id_dir scidd;
+ u32 timestamp;
+ u8 message_flags, channel_flags;
+ u16 cltv_expiry_delta;
+ struct amount_msat htlc_minimum_msat, htlc_maximum_msat;
+ u32 fee_base_msat, fee_proportional_millionths;
+ struct out_req *req;
+ const struct gossmap *gossmap;
+ const struct gossmap_chan *c;
+
+ /* Identify failcodes that have some channel_update.
+ *
+ * TODO > BOLT 1.0: Add new failcodes when updating to a
+ * new BOLT version. */
+ if (!fromwire_temporary_channel_failure(tmpctx,
+ onion_message,
+ &channel_update) &&
+ !fromwire_amount_below_minimum(tmpctx,
+ onion_message, &unused_msat,
+ &channel_update) &&
+ !fromwire_fee_insufficient(tmpctx,
+ onion_message, &unused_msat,
+ &channel_update) &&
+ !fromwire_incorrect_cltv_expiry(tmpctx,
+ onion_message, &unused32,
+ &channel_update) &&
+ !fromwire_expiry_too_soon(tmpctx,
+ onion_message,
+ &channel_update))
+ /* No channel update. */
+ return false;
+
+ /* LND before v0.18 (May 2024) would not include the
+ * WIRE_CHANNEL_UPDATE type field, but now they do. */
+ if (!fromwire_channel_update(channel_update,
+ &signature,
+ &chain_hash,
+ &scidd.scid,
+ ×tamp,
+ &message_flags,
+ &channel_flags,
+ &cltv_expiry_delta,
+ &htlc_minimum_msat,
+ &fee_base_msat,
+ &fee_proportional_millionths,
+ &htlc_maximum_msat))
+ return false;
+
+ scidd.dir = (channel_flags & ROUTING_FLAGS_DIRECTION);
+
+ /* If this is substantially the same as the one we already have, ignore it. */
+ gossmap = get_gossmap(xpay_of(aux_cmd->plugin));
+ c = gossmap_find_chan(gossmap, &scidd.scid);
+ if (c) {
+ const struct half_chan *hc = &c->half[scidd.dir];
+ if (gossmap_chan_set(c, scidd.dir)
+ && hc->enabled == !(channel_flags & ROUTING_FLAGS_DISABLED)
+ /* We convert the same way gossmap.c does */
+ && u64_to_fp16(htlc_minimum_msat.millisatoshis, false) == hc->htlc_min /* Raw: convert */
+ && u64_to_fp16(htlc_maximum_msat.millisatoshis, true) == hc->htlc_max /* Raw: convert */
+ && fee_base_msat == hc->base_fee
+ && fee_proportional_millionths == hc->proportional_fee
+ && cltv_expiry_delta == hc->delay) {
+ return false;
+ }
+ }
+
+ attempt_log(attempt, LOG_DBG, "Got channel_update from error for %s: %s",
+ fmt_short_channel_id_dir(tmpctx, &scidd),
+ tal_hex(tmpctx, channel_update));
+
+ /* Update our local layer so it applies to this payment *only*. We
+ * don't bother checking the signature; we don't even check what
+ * channel it is! */
+ req = payment_ignored_req(aux_cmd, attempt, "askrene-update-channel");
+ json_add_string(req->js, "layer", attempt->payment->private_layer);
+ json_add_short_channel_id_dir(req->js,
+ "short_channel_id_dir",
+ scidd);
+ json_add_bool(req->js, "enabled", !(channel_flags & ROUTING_FLAGS_DISABLED));
+ json_add_amount_msat(req->js, "htlc_minimum_msat", htlc_minimum_msat);
+ json_add_amount_msat(req->js, "htlc_maximum_msat", htlc_maximum_msat);
+ json_add_u32(req->js, "fee_base_msat", fee_base_msat);
+ json_add_u32(req->js, "fee_proportional_millionths", fee_proportional_millionths);
+ json_add_u32(req->js, "cltv_expiry_delta", cltv_expiry_delta);
+ send_payment_req(aux_cmd, attempt->payment, req);
+
+ /* We also bias *against* the channel. This should help if the node is
+ * stuck somehow, or trying to track us. */
+ req = payment_ignored_req(aux_cmd, attempt, "askrene-bias-channel");
+ json_add_string(req->js, "layer", attempt->payment->private_layer);
+ json_add_short_channel_id_dir(req->js,
+ "short_channel_id_dir",
+ scidd);
+ json_add_s32(req->js, "bias", -1);
+ json_add_string(req->js, "description",
+ tal_fmt(tmpctx, "negative bias due to channel_update in error %s",
+ errname));
+ json_add_bool(req->js, "relative", true);
+ send_payment_req(aux_cmd, attempt->payment, req);
+ return true;
+}
+
static void update_knowledge_from_error(struct command *aux_cmd,
const char *buf,
const jsmntok_t *error,
@@ -826,6 +940,15 @@ static void update_knowledge_from_error(struct command *aux_cmd,
}
} else {
/* Non-final node */
+ if (process_channel_update_from_onion_error(aux_cmd, attempt,
+ replymsg, errmsg)) {
+ add_result_summary(attempt, LOG_DBG,
+ "We got %s for %s, containing a channel_update:"
+ " updating our map",
+ errmsg, describe_scidd(attempt, index));
+ goto check_previous_success;
+ }
+
switch (failcode) {
/* These ones are weird any time (did we encode wrongly?) */
case WIRE_INVALID_ONION_VERSION:
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index 6cada7a8..c1e241a5 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -16,6 +16,7 @@ import sys
from hashlib import sha256
from pathlib import Path
import tempfile
+import time
import unittest
@@ -1075,6 +1076,71 @@ def test_xpay_blockheight_mismatch(node_factory, bitcoind, executor):
fut.result(TIMEOUT)
+def test_xpay_get_error_with_update(node_factory):
+ """We should process an update inside a temporary_channel_failure"""
+ l1, l2, l3 = node_factory.line_graph(3, opts={'log-level': 'io'}, fundchannel=True, wait_for_announce=True)
+ chanid2 = l2.get_channel_scid(l3)
+
+ inv = l3.rpc.invoice(123000, 'test_xpay_get_error_with_update', 'description')
+
+ # Make sure it's not doing startup any more (where it doesn't disable channels!)
+ l2.daemon.wait_for_log("channel_gossip: no longer in startup mode", timeout=70)
+
+ # Make sure l2 doesn't tell l1 directly that channel is disabled.
+ l2.rpc.dev_suppress_gossip()
+ l3.stop()
+
+ # Make sure that l2 has seen disconnect, considers channel disabled.
+ wait_for(lambda: only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['peer_connected'] is False)
+
+ assert(l1.is_channel_active(chanid2))
+
+ with pytest.raises(RpcError, match=r'temporary_channel_failure'):
+ l1.rpc.xpay(inv['bolt11'])
+
+ # Make sure we get an onionreply, without the type prefix of the nested
+ # channel_update, and it should patch it to include a type prefix. The
+ # prefix 0x0102 should be in the channel_update, but not in the
+ # onionreply (negation of 0x0102 in the RE)
+ l1.daemon.wait_for_log(rf'Got channel_update from error for {chanid2}/0: 0102')
+
+ # But this update is only for this one, not future ones!
+ time.sleep(5)
+ assert l1.is_channel_active(chanid2)
+
+
+def test_xpay_error_update_fees(node_factory):
+ """We should process an update inside a temporary_channel_failure"""
+ l1, l2, l3 = node_factory.line_graph(3, fundchannel=True, wait_for_announce=True)
+
+ # Don't include any routehints in first invoice.
+ inv1 = l3.dev_invoice(amount_msat=123000,
+ label='test_xpay_error_update_fees',
+ description='description',
+ dev_routes=[])
+
+ inv2 = l3.rpc.invoice(123000, 'test_xpay_error_update_fees2', 'desc')
+ assert 'routes' not in l1.rpc.decode(inv1['bolt11'])
+ assert 'routes' in l1.rpc.decode(inv2['bolt11'])
+
+ # Make sure l2 doesn't tell l1 directly that channel fee is changed.
+ l2.rpc.dev_suppress_gossip()
+ l2.rpc.setchannel(l3.info['id'], 1337, 137, enforcedelay=0)
+
+ # Should bounce off and retry...
+ ret = l1.rpc.xpay(inv1['bolt11'])
+ assert ret["failed_parts"] == 1
+ assert ret["successful_parts"] == 1
+ l1.daemon.wait_for_log('We got fee_insufficient for .*, containing a channel_update: updating our map')
+
+ # This will have to do the same, since we don't remember such updates. It will
+ # even fix the routehint which is (now) wrong.
+ ret = l1.rpc.xpay(inv2['bolt11'])
+ assert ret["failed_parts"] == 1
+ assert ret["successful_parts"] == 1
+ l1.daemon.wait_for_log('We got fee_insufficient for .*, containing a channel_update: updating our map')
+
+
def test_error_messages(node_factory):
"""Nicer error messages when we disable the only channel to the destination."""
plugin = os.path.join(os.path.dirname(__file__), 'plugins/replace_payload.py')
Why this scored 37/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.