lightningd: refuse to let a peer open a channel if we have no fee estimates.
What changed, and why it matters
This change stops Core Lightning from accepting new incoming payment channels when the node cannot estimate current Bitcoin transaction fees. Previously, the node might accept a channel while assuming a very low fallback fee. That could lead to problems later if the real network fees were much higher, because the channel's agreed fee range might be too low to get transactions confirmed promptly. The patch adds a safety check that rejects incoming channel offers with a clear 'feerates unknown' message until fee estimates become available.
Treat as a hardening/defensive fix with possible security relevance. Review whether any other incoming-channel or splice paths can proceed without feerate estimates, and consider backporting to maintained release branches because the change is small, self-contained, and closes a known risky edge case.
Security signals we found
Adds a guard condition that rejects protocol state advancement when a safety-critical input (fee estimate) is missing
Applies the guard to both supported channel-open protocol variants (v1 and v2/dual-funding)
Explicitly overrides the 'ignore-feerates' developer option for incoming channels, preventing accidental bypass
Modifies an existing test that previously expected the old behavior, confirming the change is intentional and behavior-altering
Removes an xfail marker from a test named 'test_opening_incoming_unknown_feerates', indicating the previously known/failing scenario is now fixed
Evidence from the diff
The commit introduces unknown_feerates() in chaintopology.c/h and calls it early in the incoming-channel acceptance paths of both v1 (opening_control.c) and v2/dual-funding (dual_open_control.c) channel open protocols. If no feerate estimates exist, the accepter immediately sends a failure message to the peer instead of proceeding to plugin hooks or fee-range negotiation. Tests are updated to assert that incoming channels are rejected under mocked fee-estimation failure, while existing channels and outgoing connections continue to work.
Changed components
lightningd/chaintopology.clightningd/chaintopology.hlightningd/dual_open_control.clightningd/opening_control.ctests/test_connection.pytests/test_opening.pyInspect captured patch +48 / −17
diff --git a/lightningd/chaintopology.c b/lightningd/chaintopology.c
index 05e356c6..bc5248b8 100644
--- a/lightningd/chaintopology.c
+++ b/lightningd/chaintopology.c
@@ -381,6 +381,11 @@ static void watch_for_unconfirmed_txs(struct lightningd *ld,
/* Mutual recursion via timer. */
static void next_updatefee_timer(struct chain_topology *topo);
+bool unknown_feerates(const struct chain_topology *topo)
+{
+ return tal_count(topo->feerates[0]) == 0;
+}
+
static u32 interp_feerate(const struct feerate_est *rates, u32 blockcount)
{
const struct feerate_est *before = NULL, *after = NULL;
diff --git a/lightningd/chaintopology.h b/lightningd/chaintopology.h
index 451113cd..9a69f34c 100644
--- a/lightningd/chaintopology.h
+++ b/lightningd/chaintopology.h
@@ -186,6 +186,9 @@ u32 smoothed_feerate_for_deadline(const struct chain_topology *topo, u32 blockco
/* Get feerate to hit this *block number*. */
u32 feerate_for_target(const struct chain_topology *topo, u64 deadline);
+/* Has our feerate estimation failed altogether? */
+bool unknown_feerates(const struct chain_topology *topo);
+
/* Get range of feerates to insist other side abide by for normal channels.
* If we have to guess, sets *unknown to true, otherwise false. */
u32 feerate_min(struct lightningd *ld, bool *unknown);
diff --git a/lightningd/dual_open_control.c b/lightningd/dual_open_control.c
index f5f63b7b..628b9c90 100644
--- a/lightningd/dual_open_control.c
+++ b/lightningd/dual_open_control.c
@@ -2081,6 +2081,15 @@ static void accepter_got_offer(struct subd *dualopend,
return;
}
+ /* Don't allow opening if we don't know any fees; even if
+ * ignore-feerates is set. */
+ if (unknown_feerates(dualopend->ld->topology)) {
+ subd_send_msg(dualopend,
+ take(towire_dualopend_fail(NULL, "Cannot accept channel: feerates unknown")));
+ tal_free(payload);
+ return;
+ }
+
/* As a convenience to the plugin, we provide our current known
* min + max feerates. Ideally, the plugin will fail to
* contribute funds if the peer's feerate range is outside of
diff --git a/lightningd/opening_control.c b/lightningd/opening_control.c
index 9263ae6c..d7998197 100644
--- a/lightningd/opening_control.c
+++ b/lightningd/opening_control.c
@@ -872,6 +872,16 @@ static void opening_got_offer(struct subd *openingd,
return;
}
+ /* Don't allow opening if we don't know any fees; even if
+ * ignore-feerates is set. */
+ if (unknown_feerates(openingd->ld->topology)) {
+ subd_send_msg(openingd,
+ take(towire_openingd_got_offer_reply(NULL, "Cannot accept channel: feerates unknown",
+ NULL, NULL, NULL, 0)));
+ tal_free(payload);
+ return;
+ }
+
tal_add_destructor2(openingd, openchannel_payload_remove_openingd, payload);
plugin_hook_call_openchannel(openingd->ld, NULL, payload);
}
diff --git a/tests/test_connection.py b/tests/test_connection.py
index 87ee2b66..db51651c 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -2884,7 +2884,8 @@ def test_fundee_node_unconfirmed(node_factory, bitcoind):
def test_no_fee_estimate(node_factory, bitcoind, executor):
- l1 = node_factory.get_node(start=False, options={'dev-no-fake-fees': True})
+ l1 = node_factory.get_node(start=False, options={'dev-no-fake-fees': True},
+ may_reconnect=True)
# Fail any fee estimation requests until we allow them further down
l1.daemon.rpcproxy.mock_rpc('estimatesmartfee', {
@@ -2892,7 +2893,7 @@ def test_no_fee_estimate(node_factory, bitcoind, executor):
})
l1.start()
- l2 = node_factory.get_node()
+ l2 = node_factory.get_node(may_reconnect=True)
l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
# Can't fund a channel.
@@ -2929,9 +2930,19 @@ def test_no_fee_estimate(node_factory, bitcoind, executor):
with pytest.raises(RpcError, match=r'Cannot estimate fees'):
l1.rpc.fundchannel(l2.info['id'], 10**6, '2000perkw', minconf=0)
- # But can accept incoming connections.
+ # Can accept incoming connections, can't allow incoming channels
l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
+ with pytest.raises(RpcError, match=r'feerates unknown'):
+ l2.fundchannel(l1, 10**6)
+
+ # Re-enable fees for a moment so we can fund channel.
+ l1.set_feerates((15000, 11000, 7500, 3750), True)
l2.fundchannel(l1, 10**6)
+ l1.daemon.rpcproxy.mock_rpc('estimatesmartfee', {
+ 'error': {"errors": ["Insufficient data or no feerate found"], "blocks": 0}
+ })
+ l1.restart()
+ l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
# Can do HTLCs.
l2.pay(l1, 10**5)
@@ -2943,8 +2954,14 @@ def test_no_fee_estimate(node_factory, bitcoind, executor):
sync_blockheight(bitcoind, [l1, l2])
# Can do unilateral close.
- l2.rpc.connect(l1.info['id'], 'localhost', l1.port)
+ l1.set_feerates((15000, 11000, 7500, 3750), True)
l2.fundchannel(l1, 10**6)
+ l1.daemon.rpcproxy.mock_rpc('estimatesmartfee', {
+ 'error': {"errors": ["Insufficient data or no feerate found"], "blocks": 0}
+ })
+ l1.restart()
+ l2.rpc.connect(l1.info['id'], 'localhost', l1.port)
+
l2.pay(l1, 10**9 // 2)
l1.rpc.dev_fail(l2.info['id'])
l1.daemon.wait_for_log('Failing due to dev-fail command')
@@ -2954,18 +2971,6 @@ def test_no_fee_estimate(node_factory, bitcoind, executor):
bitcoind.generate_block(100)
sync_blockheight(bitcoind, [l1, l2])
- # Start estimatesmartfee.
- l1.set_feerates((15000, 11000, 7500, 3750), True)
-
- # Can now fund a channel (as a test, use slow feerate).
- l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
- sync_blockheight(bitcoind, [l1])
- l1.rpc.fundchannel(l2.info['id'], 10**6, 'slow')
-
- # Can withdraw (use urgent feerate). `minconf` may be needed depending on
- # the previous `fundchannel` selecting all confirmed outputs.
- l1.rpc.withdraw(l2.rpc.newaddr('bech32')['bech32'], 'all', 'urgent', minconf=0)
-
def test_opener_feerate_reconnect(node_factory, bitcoind):
# l1 updates fees, then reconnect so l2 retransmits commitment_signed.
diff --git a/tests/test_opening.py b/tests/test_opening.py
index 39ae47e1..4954c31b 100644
--- a/tests/test_opening.py
+++ b/tests/test_opening.py
@@ -2931,7 +2931,6 @@ def test_zeroconf_withhold(node_factory, bitcoind, stay_withheld, mutual_close):
@pytest.mark.openchannel('v1')
@pytest.mark.openchannel('v2')
-@pytest.mark.xfail(strict=True)
def test_opening_incoming_unknown_feerates(node_factory, bitcoind):
"""
Don't allow incoming channels if we can't estimate feerates.
Why this scored 45/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.