splice: Make feerate opening + feerate_offset
What changed, and why it matters
This commit fixes a bug in Core Lightning's splicing feature where the fee rate used for splice transactions was accidentally based on the much higher 'unilateral close' fee rate instead of the intended 'opening' fee rate plus a small offset. The result was that normal splice-in and splice-out operations could be rejected or aborted because the calculated fee looked too high. The fix introduces a dedicated splice fee rate and updates the safety checks and tests accordingly. It is a correctness/availability issue rather than a remote exploit, but it could cause users to pay excessive fees or fail to use splicing.
Apply the patch. After upgrading, operators using splicing should see splice transactions use the lower, correct opening-based fee rate. Nodes that have not yet upgraded may experience splice aborts or overpayment when network unilateral-close feerates spike. No immediate incident response is required beyond normal patching.
Security signals we found
Incorrect fee-rate source used for splice transactions (unilateral close rate instead of opening rate)
New dedicated splice_feerate helper clamps value to feerate_max to prevent runaway fees
Balance safety checks in channeld updated to use max feerate boundary
Changelog explicitly calls this a 'high fee issue' fix
No remote attacker-controlled input identified; issue is operational/fee policy
Evidence from the diff
Prior to this commit, splice fee rate was derived from default_feerate(…, true), which resolves to unilateral_feerate (the feerate used for unilateral/force closes). That is substantially higher than the intended opening feerate. The patch adds splice_feerate(topo, ld) in chaintopology, defined as opening_feerate + feerate_offset, capped at feerate_max. It wires this new feerate into channel_control.c and channeld.c, replacing default_feerate for splice operations. The channeld balance checks now compare against a new peer->feerate_max field (renamed from feerate_opening in this context) and include extra debug values in error messages. Test expectations are updated to reflect the lower, correct splice feerate.
Changed components
lightningd/chaintopology.clightningd/chaintopology.hlightningd/channel_control.cchanneld/channeld.cSplicing RPC path (splice_init, splice_update, splice)Fee-rate reporting via json_feeratesInspect captured patch +60 / −50
diff --git a/channeld/channeld.c b/channeld/channeld.c
index 11d8fdee..ed4cc6e8 100644
--- a/channeld/channeld.c
+++ b/channeld/channeld.c
@@ -3600,9 +3600,9 @@ static struct amount_sat check_balances(struct peer *peer,
/* As a safeguard max feerate is checked (only) locally, if it's
* particularly high we fail and tell the user but allow them to
* override with `splice_force_feerate` */
- max_accepter_fee = amount_tx_fee(peer->feerate_opening,
+ max_accepter_fee = amount_tx_fee(peer->feerate_max,
calc_weight(TX_ACCEPTER, psbt, false));
- max_initiator_fee = amount_tx_fee(peer->feerate_opening,
+ max_initiator_fee = amount_tx_fee(peer->feerate_max,
calc_weight(TX_INITIATOR, psbt, opener));
if (opener) {
@@ -3620,10 +3620,13 @@ static struct amount_sat check_balances(struct peer *peer,
false);
wire_sync_write(MASTER_FD, take(msg));
splice_abort(peer, NULL,
- "%s fee (%s) was too low, must be at least %s",
+ "%s fee (%s) was too low, must be at least %s"
+ " weight: %"PRIu64", splicing->feerate_per_kw: %"PRIu32,
opener ? "Our" : "Your",
fmt_amount_msat(tmpctx, initiator_fee),
- fmt_amount_sat(tmpctx, min_initiator_fee));
+ fmt_amount_sat(tmpctx, min_initiator_fee),
+ calc_weight(TX_INITIATOR, psbt, false),
+ peer->splicing->feerate_per_kw);
}
if (!peer->splicing->force_feerate && opener
&& amount_msat_greater_sat(initiator_fee, max_initiator_fee)) {
@@ -3660,7 +3663,7 @@ static struct amount_sat check_balances(struct peer *peer,
fmt_amount_msat(tmpctx, accepter_fee),
fmt_amount_sat(tmpctx, min_accepter_fee),
calc_weight(TX_INITIATOR, psbt, false),
- peer->feerate_opening);
+ peer->feerate_max);
}
if (!peer->splicing->force_feerate && !opener
&& amount_msat_greater_sat(accepter_fee, max_accepter_fee)) {
diff --git a/lightningd/chaintopology.c b/lightningd/chaintopology.c
index bc5248b8..e59b9c63 100644
--- a/lightningd/chaintopology.c
+++ b/lightningd/chaintopology.c
@@ -617,6 +617,17 @@ u32 opening_feerate(struct chain_topology *topo)
conversions[FEERATE_OPENING].blockcount);
}
+u32 splice_feerate(struct chain_topology *topo, struct lightningd *ld)
+{
+ u32 rate = opening_feerate(topo);
+ if (!rate)
+ return 0;
+ rate += ld->config.feerate_offset;
+ if (rate > feerate_max(ld, NULL))
+ rate = feerate_max(ld, NULL);
+ return rate;
+}
+
u32 mutual_close_feerate(struct chain_topology *topo)
{
if (topo->ld->force_feerates)
@@ -717,14 +728,9 @@ static struct command_result *json_feerates(struct command *cmd,
if (rate)
json_add_num(response, "penalty",
feerate_to_style(rate, *style));
- rate = unilateral_feerate(topo, true);
- if (rate) {
- rate += cmd->ld->config.feerate_offset;
- if (rate > feerate_max(cmd->ld, NULL))
- rate = feerate_max(cmd->ld, NULL);
- json_add_num(response, "splice",
- feerate_to_style(rate, *style));
- }
+ rate = splice_feerate(topo, cmd->ld);
+ if (rate)
+ json_add_num(response, "splice", feerate_to_style(rate, *style));
json_add_u64(response, "min_acceptable",
feerate_to_style(feerate_min(cmd->ld, NULL), *style));
diff --git a/lightningd/chaintopology.h b/lightningd/chaintopology.h
index 9a69f34c..11baec04 100644
--- a/lightningd/chaintopology.h
+++ b/lightningd/chaintopology.h
@@ -196,6 +196,7 @@ u32 feerate_max(struct lightningd *ld, bool *unknown);
/* These return 0 if unknown */
u32 opening_feerate(struct chain_topology *topo);
+u32 splice_feerate(struct chain_topology *topo, struct lightningd *ld);
u32 mutual_close_feerate(struct chain_topology *topo);
u32 unilateral_feerate(struct chain_topology *topo, bool option_anchors);
/* For onchain resolution. */
diff --git a/lightningd/channel_control.c b/lightningd/channel_control.c
index 55b8a381..ee707bca 100644
--- a/lightningd/channel_control.c
+++ b/lightningd/channel_control.c
@@ -81,7 +81,7 @@ void channel_update_feerates(struct lightningd *ld, const struct channel *channe
u32 min_feerate, max_feerate;
bool anchors = channel_type_has_anchors(channel->type);
u32 feerate = default_feerate(ld, channel, (channel->opener == LOCAL));
- u32 feerate_splice = default_feerate(ld, channel, true);
+ u32 feerate_splice = splice_feerate(ld->topology, ld);
/* Nothing to do if we don't know feerate. */
if (!feerate)
@@ -1900,7 +1900,7 @@ bool peer_start_channeld(struct channel *channel,
tal_arr_expand(&inflights, infcopy);
}
- feerate_splice = default_feerate(ld, channel, true);
+ feerate_splice = splice_feerate(ld->topology, ld);
initmsg = towire_channeld_init(tmpctx,
chainparams,
@@ -2347,7 +2347,7 @@ static struct command_result *json_splice_init(struct command *cmd,
if (!feerate_per_kw) {
feerate_per_kw = tal(cmd, u32);
- *feerate_per_kw = default_feerate(cmd->ld, channel, true);
+ *feerate_per_kw = splice_feerate(cmd->ld->topology, cmd->ld);
}
if (!initialpsbt)
@@ -2720,7 +2720,7 @@ static struct command_result *json_dev_feerate(struct command *cmd,
feerate_max(cmd->ld, NULL),
penalty_feerate(cmd->ld->topology),
opening_feerate(cmd->ld->topology),
- default_feerate(cmd->ld, channel, true));
+ splice_feerate(cmd->ld->topology, cmd->ld));
subd_send_msg(channel->owner, take(msg));
response = json_stream_success(cmd);
diff --git a/tests/test_askrene.py b/tests/test_askrene.py
index 52db13e2..99832448 100644
--- a/tests/test_askrene.py
+++ b/tests/test_askrene.py
@@ -2113,8 +2113,8 @@ def test_splice_dying_channel(node_factory, bitcoind):
funds_result = l1.rpc.addpsbtoutput(100000)
pre_splice_scidd = first_scidd(l1, l2)
- # Pay with fee by subjtracting 5000 from channel balance
- result = l1.rpc.splice_init(chan_id, -105000, funds_result['psbt'])
+ # Pay with fee by subjtracting 5801 from channel balance
+ result = l1.rpc.splice_init(chan_id, -105801, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
assert(result['commitments_secured'] is False)
result = l1.rpc.splice_update(chan_id, result['psbt'])
diff --git a/tests/test_misc.py b/tests/test_misc.py
index e226a37e..4154ad6e 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -2274,7 +2274,7 @@ def test_bitcoind_feerate_floor(node_factory, bitcoind, anchors):
"unilateral_close": 44000,
'unilateral_anchor_close': 15000,
"penalty": 30000,
- "splice": 15020,
+ "splice": 30020,
"min_acceptable": 7500,
"max_acceptable": 600000,
"floor": 1012,
@@ -2318,7 +2318,7 @@ def test_bitcoind_feerate_floor(node_factory, bitcoind, anchors):
# This has increased (rounded up)
"unilateral_close": 44000,
"penalty": 30000,
- "splice": 20024,
+ "splice": 30020,
# This has increased (rounded up)
"min_acceptable": 20004,
"max_acceptable": 600000,
@@ -3882,7 +3882,7 @@ def test_force_feerates(node_factory):
"unilateral_close": 2222,
"unilateral_anchor_close": 2222,
"penalty": 2222,
- "splice": 2227,
+ "splice": 1116,
"min_acceptable": 1875,
"max_acceptable": 150000,
"estimates": estimates,
@@ -3899,7 +3899,7 @@ def test_force_feerates(node_factory):
"unilateral_close": 3333,
"unilateral_anchor_close": 3333,
"penalty": 6666,
- "splice": 3338,
+ "splice": 1116,
"min_acceptable": 1875,
"max_acceptable": 150000,
"estimates": estimates,
diff --git a/tests/test_splice.py b/tests/test_splice.py
index b4658051..2c20c203 100644
--- a/tests/test_splice.py
+++ b/tests/test_splice.py
@@ -488,9 +488,9 @@ def test_script_two_chan_splice_inout(node_factory, bitcoind):
chan_id2 = l2.get_channel_id(l3)
# move sats from chan 2 into chan 1
- # By adding 10000 from wallet, the fee will be taken from this and the
+ # By adding 100000 from wallet, the fee will be taken from this and the
# extra placed back into the wallet by default
- result = l2.rpc.splice(f"wallet -> 10000; 100000 -> {chan_id1}; {chan_id2} -> 100000")
+ result = l2.rpc.splice(f"wallet -> 100000; 100000 -> {chan_id1}; {chan_id2} -> 100000")
l3.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
@@ -639,7 +639,7 @@ def execute_script(node_factory, bitcoind, script, expected_balances=None, fee_m
@pytest.mark.openchannel('v2')
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
def test_script_two_chan_splice_b(node_factory, bitcoind):
- execute_script(node_factory, bitcoind, "wallet -> 10000; {} -> 100000; {} -> 100000",
+ execute_script(node_factory, bitcoind, "wallet -> 100000; {} -> 100000; {} -> 100000",
[500000 - 100000, 500000 - 100000])
@@ -647,7 +647,7 @@ def test_script_two_chan_splice_b(node_factory, bitcoind):
@pytest.mark.openchannel('v2')
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
def test_script_two_chan_splice_c(node_factory, bitcoind):
- execute_script(node_factory, bitcoind, "wallet -> 10000; 100000 -> {}; {} -> 100000",
+ execute_script(node_factory, bitcoind, "wallet -> 100000; 100000 -> {}; {} -> 100000",
[500000 + 100000, 500000 - 100000])
@@ -711,8 +711,8 @@ def test_script_two_chan_splice_j(node_factory, bitcoind):
@pytest.mark.openchannel('v2')
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
def test_script_two_chan_splice_k(node_factory, bitcoind):
- execute_script(node_factory, bitcoind, "{} -> 10000; 1000 -> {}",
- [500000 - 10000, 500000 + 1000])
+ execute_script(node_factory, bitcoind, "{} -> 100000; 1000 -> {}",
+ [500000 - 100000, 500000 + 1000])
@pytest.mark.openchannel('v1')
@@ -807,8 +807,8 @@ def test_script_two_chan_splice_v(node_factory, bitcoind):
@pytest.mark.openchannel('v2')
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
def test_script_two_chan_splice_x(node_factory, bitcoind):
- execute_script(node_factory, bitcoind, "* -> wallet; * -> {}; {} -> 100000",
- [500000 + 50000, 500000 - 100000], [-0.5, 0])
+ execute_script(node_factory, bitcoind, "* -> wallet; * -> {}; {} -> 200000",
+ [500000 + 100000 + 1, 500000 - 200000], [-0.5, 0]) # (+1 to track rounded sat)
@pytest.mark.openchannel('v1')
diff --git a/tests/test_splicing.py b/tests/test_splicing.py
index 826a7f7d..8e9ba0e4 100644
--- a/tests/test_splicing.py
+++ b/tests/test_splicing.py
@@ -17,7 +17,7 @@ def test_splice(node_factory, bitcoind):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -59,7 +59,7 @@ def test_two_chan_splice_in(node_factory, bitcoind):
chan_id2 = l2.get_channel_id(l3)
# add extra sats to pay fee
- funds_result = l2.rpc.fundpsbt("205790sat", 0, 0, excess_as_change=True)
+ funds_result = l2.rpc.fundpsbt("211722sat", 0, 0, excess_as_change=True)
# Intiate splices to both channels
result = l2.rpc.splice_init(chan_id1, 100000, funds_result['psbt'])
@@ -130,8 +130,8 @@ def test_splice_rbf(node_factory, bitcoind):
funds_result = l1.rpc.addpsbtoutput(100000)
- # Pay with fee by subtracting 5000 from channel balance
- result = l1.rpc.splice_init(chan_id, -105000, funds_result['psbt'])
+ # Pay with fee by subtracting 5801 from channel balance
+ result = l1.rpc.splice_init(chan_id, -105801, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
assert(result['commitments_secured'] is False)
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -151,7 +151,7 @@ def test_splice_rbf(node_factory, bitcoind):
funds_result = l1.rpc.addpsbtoutput(100000)
# Pay with fee by subtracting 5790 from channel balance
- result = l1.rpc.splice_init(chan_id, -105790, funds_result['psbt'])
+ result = l1.rpc.splice_init(chan_id, -111722, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
assert(result['commitments_secured'] is False)
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -189,7 +189,7 @@ def test_splice_nosign(node_factory, bitcoind):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -214,7 +214,7 @@ def test_splice_gossip(node_factory, bitcoind):
pre_splice_scid = first_scid(l1, l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -277,7 +277,7 @@ def test_splice_listnodes(node_factory, bitcoind):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -314,8 +314,8 @@ def test_splice_out(node_factory, bitcoind):
funds_result = l1.rpc.addpsbtoutput(100000)
- # Pay with fee by subjtracting 5000 from channel balance
- result = l1.rpc.splice_init(chan_id, -105000, funds_result['psbt'])
+ # Pay with fee by subjtracting 5801 from channel balance
+ result = l1.rpc.splice_init(chan_id, -105801, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
assert(result['commitments_secured'] is False)
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -372,7 +372,7 @@ def test_invalid_splice(node_factory, bitcoind):
assert l1.db_query("SELECT count(*) as c FROM channel_funding_inflights;")[0]['c'] == 0
# Now we do a real splice to confirm everything works after restart
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -408,7 +408,7 @@ def test_commit_crash_splice(node_factory, bitcoind):
chan_id = l1.get_channel_id(l2)
- result = l1.rpc.splice_init(chan_id, -105000, l1.rpc.addpsbtoutput(100000)['psbt'])
+ result = l1.rpc.splice_init(chan_id, -105801, l1.rpc.addpsbtoutput(100000)['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
assert(result['commitments_secured'] is False)
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -467,7 +467,7 @@ def test_splice_stuck_htlc(node_factory, bitcoind, executor):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -513,7 +513,7 @@ def test_route_by_old_scid(node_factory, bitcoind):
route = l1.single_route(l3.info['id'], 10000000, cltv=16)
# Do a splice
- funds_result = l2.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l2.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
chan_id = l2.get_channel_id(l3)
result = l2.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l2.rpc.splice_update(chan_id, result['psbt'])
@@ -533,7 +533,7 @@ def test_route_by_old_scid(node_factory, bitcoind):
# Let's splice again, so the original scid is two behind the times.
l3.fundwallet(200000)
- funds_result = l3.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l3.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
chan_id = l3.get_channel_id(l2)
result = l3.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l3.rpc.splice_update(chan_id, result['psbt'])
@@ -566,7 +566,7 @@ def test_splice_unannounced(node_factory, bitcoind):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
assert(result['commitments_secured'] is False)
diff --git a/tests/test_splicing_disconnect.py b/tests/test_splicing_disconnect.py
index 9995fb04..a0975b0b 100644
--- a/tests/test_splicing_disconnect.py
+++ b/tests/test_splicing_disconnect.py
@@ -26,7 +26,7 @@ def test_splice_disconnect_sig(node_factory, bitcoind):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("107527sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
@@ -85,7 +85,7 @@ def test_splice_disconnect_commit(node_factory, bitcoind, executor):
chan_id = l1.get_channel_id(l2)
# add extra sats to pay fee
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("107527sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
diff --git a/tests/test_splicing_insane.py b/tests/test_splicing_insane.py
index c92f53a8..2643fb95 100644
--- a/tests/test_splicing_insane.py
+++ b/tests/test_splicing_insane.py
@@ -9,7 +9,7 @@ def make_pending_splice(node_factory):
chan_id = l1.get_channel_id(l2)
- funds_result = l1.rpc.fundpsbt("105790sat", 0, 0, excess_as_change=True)
+ funds_result = l1.rpc.fundpsbt("107527sat", 0, 0, excess_as_change=True)
result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt'])
result = l1.rpc.splice_update(chan_id, result['psbt'])
Why this scored 55/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.