dualopend: bound the feerates a peer opens at
What changed, and why it matters
This commit fixes a bug in Core Lightning's experimental dual-funded channel feature. When another node tried to open a channel, Core Lightning was not checking whether the proposed transaction fees were reasonable. A peer could request a fee of zero, an impossibly high fee, or a commitment transaction fee so low it would never confirm. Core Lightning would sign and store these values anyway. The fix adds minimum and maximum fee checks, similar to those already present for other channel-opening paths.
Apply this patch to any node running --experimental-dual-fund. Review stored channel_funding_inflights for zero or anomalous feerates from prior dual-funded opens. Consider whether related advisory guidance recommends rotating or closing affected channels.
Security signals we found
Missing input validation on wire-parsed feerate fields
Peer could induce signing and storage of feerate == 0
RBF remote path allowed unbounded upward feerate walks
Fix aligns dualopend with existing feerate bounds in channeld/openingd
Changelog-Fixed labels this as a security-relevant bug fix
Evidence from the diff
The patch plumbs min_feerate, max_feerate, and ignore_fee_limits into the dualopend daemon via dualopend_init and dualopend_reinit wire messages, then enforces bounds in accepter_start() and rbf_remote_start(). Funding feerate is bounded below by FEERATE_FLOOR and above by the policy max (or FEERATE_CEILING when ignoring limits). Commitment feerate is bounded below by feerate_min (or FEERATE_FLOOR for anchor channels) and above by the same max. This closes the open_channel2 path described in the commit message where arbitrary feerates could be signed and stored, including feerate == 0.
Changed components
lightningd/dual_open_control.copeningd/dualopend.copeningd/dualopend_wire.csvtests/test_opening.pyInspect captured patch +140 / −0
### lightningd/dual_open_control.c
@@ -4226,6 +4226,7 @@ bool peer_start_dualopend(struct peer *peer,
/* FIXME: We should override this to 0 in the openchannel2 hook of we want zeroconf*/
channel->minimum_depth = peer->ld->config.funding_confirms;
+ /* dualopend applies ignore_fee_limits itself, so these stay honest. */
msg = towire_dualopend_init(NULL, chainparams,
peer->ld->our_features,
peer->their_features,
@@ -4235,6 +4236,10 @@ bool peer_start_dualopend(struct peer *peer,
&channel->local_basepoints,
&channel->local_funding_pubkey,
channel->minimum_depth,
+ feerate_min(peer->ld, NULL),
+ feerate_max(peer->ld, NULL),
+ channel->ignore_fee_limits
+ || peer->ld->config.ignore_fee_limits,
peer->ld->config.require_confirmed_inputs,
*channel->alias[LOCAL],
peer->ld->dev_any_channel_type);
@@ -4342,6 +4347,10 @@ bool peer_restart_dualopend(struct peer *peer,
&channel->local_funding_pubkey,
&channel->channel_info.remote_fundingkey,
channel->minimum_depth,
+ feerate_min(peer->ld, NULL),
+ feerate_max(peer->ld, NULL),
+ channel->ignore_fee_limits
+ || peer->ld->config.ignore_fee_limits,
&inflight->funding->outpoint,
inflight->funding->feerate,
channel->funding_sats,
### openingd/dualopend.c
@@ -12,6 +12,7 @@
* contribute inputs to the transaction
*/
#include "config.h"
+#include <bitcoin/feerate.h>
#include <bitcoin/script.h>
#include <ccan/array_size/array_size.h>
#include <ccan/cast/cast.h>
@@ -162,6 +163,9 @@ struct state {
/* Constraints on a channel they open. */
u32 minimum_depth;
+ u32 min_feerate, max_feerate;
+ /* Drop the policy bounds above (never the sanity ceiling). */
+ bool ignore_fee_limits;
struct amount_msat min_effective_htlc_capacity;
/* Limits on what remote config we accept. */
@@ -424,6 +428,56 @@ static void negotiation_failed(struct state *state,
open_abort(state, "You gave bad parameters: %s", errmsg);
}
+/* Ignoring the fee limits drops the policy bounds, but never the sanity
+ * ceiling: a feerate above that means a broken fee source, and whatever we
+ * accept here is what we go on to store.
+ *
+ * For anchor channels the commitment only has to relay: its fee gets topped
+ * up by the anchor spend when we actually need it onchain, so the relay floor
+ * is the real bound there. Holding the opener to our *policy* minimum would
+ * refuse the very feerate we would propose ourselves, since lightningd also
+ * uses the floor for anchors (see update_feerates()). */
+static u32 accepted_commitment_feerate_min(const struct state *state)
+{
+ if (state->ignore_fee_limits)
+ return 1;
+ if (channel_type_has_anchors(state->channel_type))
+ return FEERATE_FLOOR;
+ return state->min_feerate;
+}
+
+static u32 accepted_feerate_max(const struct state *state)
+{
+ if (state->ignore_fee_limits)
+ return FEERATE_CEILING;
+ return state->max_feerate;
+}
+
+/* Both feerates in an open (or an RBF of one) come straight off the wire from
+ * the opener, and nothing downstream bounds them: the openchannel2 hook only
+ * *reports* our limits, so with no plugin hooked nothing enforces them, and
+ * check_funding_feerate() governs only the lower 25/24 RBF step. Returns
+ * false having already failed the negotiation.
+ *
+ * The two feerates want different floors, hence min_feerate: see the callers. */
+static bool feerate_in_range(struct state *state, const char *name,
+ u32 feerate, u32 min_feerate)
+{
+ if (feerate < min_feerate) {
+ negotiation_failed(state, "%s %u below minimum %u",
+ name, feerate, min_feerate);
+ return false;
+ }
+
+ if (feerate > accepted_feerate_max(state)) {
+ negotiation_failed(state, "%s %u above maximum %u",
+ name, feerate, accepted_feerate_max(state));
+ return false;
+ }
+
+ return true;
+}
+
static void billboard_update(struct state *state)
{
const char *update = billboard_message(tmpctx, state->channel_ready,
@@ -2427,6 +2481,20 @@ static void accepter_start(struct state *state, const u8 *oc2_msg)
fmt_channel_id(tmpctx, &cid));
}
+ /* Now state->channel_id is set, so an abort is one the opener can
+ * match up, check the feerates: do it before anything else we might
+ * commit to, as these are what we would go on to sign for and store.
+ *
+ * The funding feerate only has to be relayable. If the opener picks a
+ * slow one that is their problem, and RBF is the remedy, so holding it
+ * to our *policy* minimum would refuse perfectly good opens. But 0 is
+ * not a feerate, and it is precisely the value that leaves no valid
+ * next RBF step downstream, so the relay floor is the right bound. */
+ if (!feerate_in_range(state, "funding_feerate_perkw",
+ tx_state->feerate_per_kw_funding,
+ FEERATE_FLOOR))
+ return;
+
/* BOLT #2:
* The receiving node MUST fail the channel if:
*...
@@ -2458,6 +2526,16 @@ static void accepter_start(struct state *state, const u8 *oc2_msg)
}
}
+ /* The commitment feerate is a different matter: too low and the
+ * commitment we are signing cannot be relayed when we need it. This
+ * has to wait for channel_type above, since what counts as too low
+ * depends on whether we negotiated anchors. Nothing between the two
+ * commits us to anything. */
+ if (!feerate_in_range(state, "commitment_feerate_perkw",
+ state->feerate_per_kw_commitment,
+ accepted_commitment_feerate_min(state)))
+ return;
+
/* Since anchor outputs are optional, we
* only support liquidity ads if those are enabled. */
if (open_tlv->request_funds &&
@@ -3707,6 +3785,13 @@ static void rbf_remote_start(struct state *state, const u8 *rbf_msg)
goto free_rbf_ctx;
}
+ /* check_funding_feerate() only enforces the 25/24 step upwards, so
+ * without this an RBF can walk the feerate up without limit. */
+ if (!feerate_in_range(state, "funding_feerate_perkw",
+ tx_state->feerate_per_kw_funding,
+ FEERATE_FLOOR))
+ goto free_rbf_ctx;
+
/* We ask master if this is ok */
msg = towire_dualopend_got_rbf_offer(NULL,
&state->channel_id,
@@ -4340,6 +4425,9 @@ int main(int argc, char *argv[])
&state->our_points,
&state->our_funding_pubkey,
&state->minimum_depth,
+ &state->min_feerate,
+ &state->max_feerate,
+ &state->ignore_fee_limits,
&state->require_confirmed_inputs[LOCAL],
&state->local_alias,
&state->dev_accept_any_channel_type)) {
@@ -4379,6 +4467,9 @@ int main(int argc, char *argv[])
&state->our_funding_pubkey,
&state->their_funding_pubkey,
&state->minimum_depth,
+ &state->min_feerate,
+ &state->max_feerate,
+ &state->ignore_fee_limits,
&state->tx_state->funding,
&state->tx_state->feerate_per_kw_funding,
&total_funding,
### openingd/dualopend_wire.csv
@@ -29,6 +29,9 @@ msgdata,dualopend_init,our_basepoints,basepoints,
msgdata,dualopend_init,our_funding_pubkey,pubkey,
# Constraints in case the other end tries to open a channel.
msgdata,dualopend_init,minimum_depth,u32,
+msgdata,dualopend_init,min_feerate,u32,
+msgdata,dualopend_init,max_feerate,u32,
+msgdata,dualopend_init,ignore_fee_limits,bool,
msgdata,dualopend_init,require_confirmed_inputs,bool,
msgdata,dualopend_init,local_alias,short_channel_id,
msgdata,dualopend_init,dev_accept_any_channel_type,bool,
@@ -49,6 +52,9 @@ msgdata,dualopend_reinit,our_basepoints,basepoints,
msgdata,dualopend_reinit,our_funding_pubkey,pubkey,
msgdata,dualopend_reinit,their_funding_pubkey,pubkey,
msgdata,dualopend_reinit,minimum_depth,u32,
+msgdata,dualopend_reinit,min_feerate,u32,
+msgdata,dualopend_reinit,max_feerate,u32,
+msgdata,dualopend_reinit,ignore_fee_limits,bool,
msgdata,dualopend_reinit,funding,bitcoin_outpoint,
msgdata,dualopend_reinit,most_recent_feerate_per_kw_funding,u32,
msgdata,dualopend_reinit,funding_satoshi,amount_sat,
### tests/test_opening.py
@@ -20,6 +20,40 @@ def find_next_feerate(node, peer):
return chan['next_feerate']
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+@pytest.mark.openchannel('v2')
+def test_v2_open_feerate_out_of_range(node_factory, bitcoind):
+ """We refuse an open_channel2 whose feerates are outside our bounds.
+
+ dualopend was never given min_feerate/max_feerate at all, and the
+ openchannel2 hook only *reports* our limits, so with no plugin hooked
+ nothing enforced them: the opener could name any feerate in either
+ direction and we would sign for it and store it.
+ """
+ # l1 has expensive estimates, l2 has cheap ones, so what l1 proposes is
+ # well above what l2 will put up with.
+ l1 = node_factory.get_node(feerates=(50000, 50000, 50000, 50000))
+ l2 = node_factory.get_node(feerates=(3000, 3000, 3000, 3000),
+ allow_warning=True)
+
+ assert l2.rpc.feerates('perkw')['perkw']['max_acceptable'] == 30000
+
+ l1.fundwallet(10**7)
+ l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
+
+ # The abort has to name the channel we're opening, or the opener can't
+ # match it up and answers "Unknown channel" instead of failing the open.
+ with pytest.raises(RpcError, match=r'funding_feerate_perkw 50000 above maximum 30000'):
+ l1.rpc.fundchannel(l2.info['id'], 500000)
+
+ l2.daemon.wait_for_log(r'funding_feerate_perkw 50000 above maximum 30000')
+ assert not l1.daemon.is_in_log(r'Unknown channel for WIRE_TX_ABORT')
+
+ # No channel, and nothing stored to trip over later.
+ assert l2.rpc.listpeerchannels()['channels'] == []
+ assert l2.db_query("SELECT count(*) AS c FROM channel_funding_inflights;")[0]['c'] == 0
+
+
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
@pytest.mark.openchannel('v2')
def test_queryrates(node_factory, bitcoind):Why this scored 78/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.