askrene: make failure messages clearer:
What changed, and why it matters
This commit only changes user-facing error messages in the Core Lightning routing plugin (askrene). It makes failure explanations clearer when payment channels are disabled, and removes double spaces from messages. There is no code behavior change, no security fix, and no vulnerability.
No security action needed; treat as a normal UX/message cleanup commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch modifies plugins/askrene/child/explain_failure.c to reword routing-failure messages and adds a special-case message when all channels to a node are disabled. It also updates corresponding test assertions in tests/test_askrene.py and tests/test_xpay.py, plus adds one new test (test_error_messages) verifying the new wording. No logic affecting route selection, channel state, cryptographic handling, or payment safety is changed.
Changed components
plugins/askrene/child/explain_failure.ctests/test_askrene.pytests/test_xpay.pyInspect captured patch +49 / −24
diff --git a/plugins/askrene/child/explain_failure.c b/plugins/askrene/child/explain_failure.c
index d97ecd01..364e41d0 100644
--- a/plugins/askrene/child/explain_failure.c
+++ b/plugins/askrene/child/explain_failure.c
@@ -137,7 +137,7 @@ static const char *check_capacity(const tal_t *ctx,
if (amount_msat_greater(amount, stats.total.capacity)) {
return child_log(ctx, LOG_DBG,
NO_USABLE_PATHS_STRING
- " Total %s capacity is only %s"
+ " Total %s capacity is only %s"
" (in %zu channels).",
name,
fmt_amount_msat(tmpctx, stats.total.capacity),
@@ -146,7 +146,7 @@ static const char *check_capacity(const tal_t *ctx,
if (amount_msat_greater(amount, stats.gossip_known.capacity)) {
return child_log(ctx, LOG_DBG,
NO_USABLE_PATHS_STRING
- " Missing gossip for %s: only known %zu/%zu channels, leaving capacity only %s of %s.",
+ " Missing gossip for %s: only known %zu/%zu channels, leaving capacity only %s of %s.",
name,
stats.gossip_known.num_channels,
stats.total.num_channels,
@@ -154,12 +154,20 @@ static const char *check_capacity(const tal_t *ctx,
fmt_amount_msat(tmpctx, stats.total.capacity));
}
if (amount_msat_greater(amount, stats.enabled.capacity)) {
+ /* Common case: one channel, disabled */
+ if (stats.enabled.num_channels == 0) {
+ return child_log(ctx, LOG_DBG,
+ NO_USABLE_PATHS_STRING
+ " All %zu channels to the %s are disabled.",
+ stats.total.num_channels,
+ name);
+ }
return child_log(ctx, LOG_DBG,
NO_USABLE_PATHS_STRING
- " The %s has disabled %zu of %zu channels, leaving capacity only %s of %s.",
- name,
+ " %zu of %zu channels to %s are disabled, leaving capacity only %s of %s.",
stats.total.num_channels - stats.enabled.num_channels,
stats.total.num_channels,
+ name,
fmt_amount_msat(tmpctx, stats.enabled.capacity),
fmt_amount_msat(tmpctx, stats.total.capacity));
}
@@ -272,7 +280,7 @@ const char *explain_failure(const tal_t *ctx,
fmt_amount_msat(tmpctx, rolling_amount));
return child_log(ctx, LOG_INFORM,
NO_USABLE_PATHS_STRING
- " The shortest path is %s, but %s %s",
+ " The shortest path is %s, but %s %s",
path,
fmt_short_channel_id_dir(tmpctx, &scidd),
explanation);
@@ -313,7 +321,7 @@ const char *explain_failure(const tal_t *ctx,
return child_log(ctx, LOG_INFORM,
NO_USABLE_PATHS_STRING
- " The shortest path is %s, but %s %s",
+ " The shortest path is %s, but %s %s",
path,
fmt_short_channel_id_dir(tmpctx, &scidd),
explanation);
diff --git a/tests/test_askrene.py b/tests/test_askrene.py
index 1a655bbf..53965fab 100644
--- a/tests/test_askrene.py
+++ b/tests/test_askrene.py
@@ -81,7 +81,7 @@ def test_reserve(node_factory):
time.sleep(2)
# Reservations can be in either order.
- with pytest.raises(RpcError, match=rf'We could not find a usable set of paths. The shortest path is {scid12}->{scid23}, but {scid12dir} already reserved 10000000*msat by command ".*" \([0-9]* seconds ago\), 10000000*msat by command ".*" \([0-9]* seconds ago\)'):
+ with pytest.raises(RpcError, match=rf'We could not find a usable set of paths. The shortest path is {scid12}->{scid23}, but {scid12dir} already reserved 10000000*msat by command ".*" \([0-9]* seconds ago\), 10000000*msat by command ".*" \([0-9]* seconds ago\)'):
l1.rpc.getroutes(source=l1.info['id'],
destination=l3.info['id'],
amount_msat=1000000,
@@ -692,7 +692,7 @@ def test_getroutes(node_factory):
# Too much should give a decent explanation.
dir01 = direction(nodemap[0], nodemap[1])
- with pytest.raises(RpcError, match=rf"We could not find a usable set of paths\. The shortest path is 0x1x0, but 0x1x0/{dir01} isn't big enough to carry 1000000001msat\."):
+ with pytest.raises(RpcError, match=rf"We could not find a usable set of paths\. The shortest path is 0x1x0, but 0x1x0/{dir01} isn't big enough to carry 1000000001msat\."):
l1.rpc.getroutes(source=nodemap[0],
destination=nodemap[1],
amount_msat=1000000001,
@@ -701,7 +701,7 @@ def test_getroutes(node_factory):
final_cltv=99)
# This should tell us source doesn't have enough.
- with pytest.raises(RpcError, match=r"We could not find a usable set of paths\. Total source capacity is only 1019000000msat \(in 3 channels\)\."):
+ with pytest.raises(RpcError, match=r"We could not find a usable set of paths\. Total source capacity is only 1019000000msat \(in 3 channels\)\."):
l1.rpc.getroutes(source=nodemap[0],
destination=nodemap[1],
amount_msat=2000000001,
@@ -710,7 +710,7 @@ def test_getroutes(node_factory):
final_cltv=99)
# This should tell us dest doesn't have enough.
- with pytest.raises(RpcError, match=r"We could not find a usable set of paths\. Total destination capacity is only 1000000000msat \(in 1 channels\)\."):
+ with pytest.raises(RpcError, match=r"We could not find a usable set of paths\. Total destination capacity is only 1000000000msat \(in 1 channels\)\."):
l1.rpc.getroutes(source=nodemap[0],
destination=nodemap[4],
amount_msat=1000000001,
@@ -724,7 +724,7 @@ def test_getroutes(node_factory):
l1.rpc.askrene_update_channel(layer="chans_disabled",
short_channel_id_dir=f'0x1x0/{dir01}',
enabled=False)
- with pytest.raises(RpcError, match=rf"We could not find a usable set of paths\. The shortest path is 0x1x0, but 0x1x0/{dir01} marked disabled by layer chans_disabled\."):
+ with pytest.raises(RpcError, match=rf"We could not find a usable set of paths\. The shortest path is 0x1x0, but 0x1x0/{dir01} marked disabled by layer chans_disabled\."):
l1.rpc.getroutes(source=nodemap[0],
destination=nodemap[1],
amount_msat=1000,
@@ -1390,7 +1390,7 @@ def test_max_htlc(node_factory, bitcoind):
amount_msat=1,
inform='constrained')
- with pytest.raises(RpcError, match=rf"We could not find a usable set of paths. The shortest path is 0x1x0, but 0x1x0/{dir01} exceeds htlc_maximum_msat ~1000448msat"):
+ with pytest.raises(RpcError, match=rf"We could not find a usable set of paths. The shortest path is 0x1x0, but 0x1x0/{dir01} exceeds htlc_maximum_msat ~1000448msat"):
l1.rpc.getroutes(source=nodemap[0],
destination=nodemap[1],
amount_msat=20_000_000,
@@ -1424,7 +1424,7 @@ def test_min_htlc_after_excess(node_factory, bitcoind):
l1 = node_factory.get_node(gossip_store_file=gsfile.name)
dir01 = direction(nodemap[0], nodemap[1])
- with pytest.raises(RpcError, match=rf"We could not find a usable set of paths. The shortest path is 0x1x0, but 0x1x0/{dir01} below htlc_minumum_msat ~2000msat"):
+ with pytest.raises(RpcError, match=rf"We could not find a usable set of paths. The shortest path is 0x1x0, but 0x1x0/{dir01} below htlc_minumum_msat ~2000msat"):
l1.rpc.getroutes(source=nodemap[0],
destination=nodemap[1],
amount_msat=1999,
@@ -1435,11 +1435,11 @@ def test_min_htlc_after_excess(node_factory, bitcoind):
# These were obviously having a bad day at the time of the snapshot:
canned_gossmap_badnodes = {
- 19: "We could not find a usable set of paths. The shortest path is 103x1x0->0x2134x0->988x333x988->16188x333x16169, but 0x2134x0/0 exceeds htlc_maximum_msat ~1000448msat",
- 53: "We could not find a usable set of paths. The destination has disabled 177 of 177 channels, leaving capacity only 0msat of 4003677000msat.",
- 69: "We could not find a usable set of paths. The destination has disabled 151 of 151 channels, leaving capacity only 0msat of 9092303000msat.",
- 72: "We could not find a usable set of paths. The destination has disabled 146 of 146 channels, leaving capacity only 0msat of 1996000000msat.",
- 86: "We could not find a usable set of paths. The destination has disabled 131 of 131 channels, leaving capacity only 0msat of 162000000msat.",
+ 19: "We could not find a usable set of paths. The shortest path is 103x1x0->0x2134x0->988x333x988->16188x333x16169, but 0x2134x0/0 exceeds htlc_maximum_msat ~1000448msat",
+ 53: r"We could not find a usable set of paths\. All 177 channels to the destination are disabled\.",
+ 69: r"We could not find a usable set of paths\. All 151 channels to the destination are disabled\.",
+ 72: r"We could not find a usable set of paths\. All 146 channels to the destination are disabled\.",
+ 86: r"We could not find a usable set of paths\. All 131 channels to the destination are disabled\.",
}
@@ -2055,7 +2055,7 @@ def test_askrene_reserve_clash(node_factory, bitcoind):
}])
# We can't use this on layer 1 anymore, only 50000 msat left.
- with pytest.raises(RpcError, match=r"We could not find a usable set of paths. The shortest path is 0x0x0, but 0x0x0/1 already reserved 950000msat by command"):
+ with pytest.raises(RpcError, match=r"We could not find a usable set of paths. The shortest path is 0x0x0, but 0x0x0/1 already reserved 950000msat by command"):
l1.rpc.getroutes(source=l1.info['id'],
destination=node1,
amount_msat=500000,
@@ -2609,7 +2609,7 @@ def test_impossible_payment(node_factory):
)
with pytest.raises(
RpcError,
- match=r"We could not find a usable set of paths. The shortest path is 0x0x1->0x0x2, but 0x0x1/0 exceeds htlc_maximum_msat",
+ match=r"We could not find a usable set of paths. The shortest path is 0x0x1->0x0x2, but 0x0x1/0 exceeds htlc_maximum_msat",
):
l1.rpc.getroutes(
source=node1,
@@ -2621,7 +2621,7 @@ def test_impossible_payment(node_factory):
)
with pytest.raises(
RpcError,
- match=r"We could not find a usable set of paths. The shortest path is 0x0x1->0x0x2, but 0x0x1/0 exceeds htlc_maximum_msat",
+ match=r"We could not find a usable set of paths. The shortest path is 0x0x1->0x0x2, but 0x0x1/0 exceeds htlc_maximum_msat",
):
l1.rpc.getroutes(
source=node1,
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index 1f9106e1..d4eeef6a 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -3,7 +3,7 @@ from fixtures import TEST_NETWORK
from pyln.client import RpcError
from pyln.testing.utils import FUNDAMOUNT, only_one
from utils import (
- TIMEOUT, first_scid, GenChannel, generate_gossip_store, wait_for,
+ TIMEOUT, first_scid, first_scidd, GenChannel, generate_gossip_store, wait_for,
sync_blockheight,
)
@@ -197,11 +197,11 @@ def test_xpay_simple(node_factory):
# Failure from l3 (with routehint)
l4.stop()
- with pytest.raises(RpcError, match=r"Failed after 1 attempts\. We got temporary_channel_failure for the invoice's route hint \([0-9x]*/[01]\), assuming it can't carry 10000msat\. Then routing failed: We could not find a usable set of paths\. The shortest path is [0-9x]*->[0-9x]*->[0-9x]*, but [0-9x]*/[01]\ layer xpay-7 says max is 9999msat"):
+ with pytest.raises(RpcError, match=r"Failed after 1 attempts\. We got temporary_channel_failure for the invoice's route hint \([0-9x]*/[01]\), assuming it can't carry 10000msat\. Then routing failed: We could not find a usable set of paths\. The shortest path is [0-9x]*->[0-9x]*->[0-9x]*, but [0-9x]*/[01]\ layer xpay-7 says max is 9999msat"):
l1.rpc.xpay(b11)
# Failure from l3 (with blinded path)
- with pytest.raises(RpcError, match=r"Failed after 1 attempts. We got an error from inside the blinded path 0x0x0/1: we assume it means insufficient capacity. Then routing failed: We could not find a usable set of paths. The shortest path is [0-9x]*->[0-9x]*->0x0x0, but 0x0x0/1 layer xpay-8 says max is 99999msat"):
+ with pytest.raises(RpcError, match=r"Failed after 1 attempts. We got an error from inside the blinded path 0x0x0/1: we assume it means insufficient capacity. Then routing failed: We could not find a usable set of paths. The shortest path is [0-9x]*->[0-9x]*->0x0x0, but 0x0x0/1 layer xpay-8 says max is 99999msat"):
l1.rpc.xpay(b12)
# Restart, try pay already paid one again.
@@ -1074,6 +1074,23 @@ def test_xpay_blockheight_mismatch(node_factory, bitcoind, executor):
fut.result(TIMEOUT)
+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')
+ l1, l2 = node_factory.line_graph(
+ 2,
+ opts=[{}, {"plugin": plugin}],
+ wait_for_announce=True
+ )
+
+ # Replace with an invalid payload.
+ l2.rpc.call('setpayload', ['0000'])
+ inv = l2.rpc.invoice(123, 'test_error_messages', 'test_error_messages')['bolt11']
+ scidd = first_scidd(l1, l2)
+ with pytest.raises(RpcError, match=rf"Failed after 1 attempts\. Unexpected error \(invalid_onion_payload\) from final node: disabling {scidd} for this payment\. Then routing failed: We could not find a usable set of paths\. All 1 channels to the source are disabled."):
+ l1.rpc.xpay(inv)
+
+
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
Why this scored 15/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.