tests: regression coverage for out-of-range feerates
What changed, and why it matters
This commit adds regression tests for three related bugs where wildly wrong Bitcoin transaction feerates could enter Core Lightning. In the worst case, a malicious or broken fee source could make the node think a feerate was zero (due to a 32-bit integer overflow), causing transactions to be mined very slowly or not at all. Another bug let an attacker plant an out-of-range feerate in the database, which then crashed the node every time it started because a plugin called listpeerchannels before the RPC was available. A third bug allowed a peer to propose a splice with an absurdly high feerate that the accepting node did not previously reject. The commit only contains tests; the actual fixes are implied to exist in the code the tests exercise.
Review the non-test commits that these regression tests protect (especially the widened conversion, sanity ceiling, database clamping migrations, and accepter-side splice feerate bound) to confirm the fixes are present and correctly implemented. Run the new tests against a build without the fixes to validate they fail as expected. Consider whether additional input validation is needed for feerate parameters in RPC and peer messages.
Security signals we found
Integer overflow in feerate conversion (u32 wrap from 0xFFFFFFFF perkb to 0 perkw)
Absurd feerate from external fee source bypassing sanity ceiling
Database-stored out-of-range feerate causing startup abort/crash loop
Missing upper bound on feerate accepted from peer during splicing
Regression tests added for three distinct attack/fix paths
Evidence from the diff
The commit introduces three regression tests for out-of-range feerate handling. test_feerate_ceiling verifies that bcli’s u32 ceiling of 0xFFFFFFFF perkb no longer wraps to 0 perkw during perkb->perkw conversion and is instead clamped to the sanity ceiling (1,000,000 perkw). test_splice_stored_feerate_repaired_on_upgrade plants malformed funding_feerate values (UINT_MAX, negative INT_MAX representation, too-large, and zero) into channel_funding_inflights, rewinds the DB version to re-run clamping migrations, and verifies both repair and that listpeerchannels no longer aborts. test_splice_feerate_too_high verifies the accepter-side upper bound on splice feerates by forcing an initiator to propose 200,000 perkw and confirming the peer rejects it. The commit is a cherry-pick and contains only tests, so the fixes must be evaluated in the context of the surrounding code.
Changed components
bcli fee estimator / feerate sourceperkb-to-perkw feerate conversionchannel_funding_inflights database tabledatabase migration / upgrade pathlistpeerchannels RPC read pathsplice_init / splice negotiation protocol handlingInspect captured patch +137 / −0
### tests/test_misc.py
@@ -2016,6 +2016,35 @@ def test_feerates(node_factory, anchors):
assert htlc_success_cost == htlc_feerate * 703 // 1000
+@unittest.skipIf(TEST_NETWORK == 'liquid-regtest', "Fees on elements are different")
+def test_feerate_ceiling(node_factory):
+ """A broken fee source can't feed absurd feerates into the daemon."""
+ l1 = node_factory.get_node()
+
+ # bcli trims anything wider than a u32 of perkb down to exactly
+ # 0xFFFFFFFF. That is also the interesting value for the conversion:
+ # (0xFFFFFFFF + 3) / 4 wraps to 0 on a u32, so before the conversion was
+ # widened this arrived as 0perkw and was quietly raised to the floor,
+ # i.e. an absurd fee source produced an absurdly *low* feerate and the
+ # ceiling never saw it.
+ def absurd_feerate(r):
+ return {'id': r['id'], 'error': None,
+ 'result': {'feerate': Decimal(900000)}}
+
+ l1.daemon.rpcproxy.mock_rpc('estimatesmartfee', absurd_feerate)
+ l1.restart()
+
+ l1.daemon.wait_for_log(r'is above sanity ceiling \(1000000\): clamping!')
+
+ feerates = l1.rpc.feerates('perkw')['perkw']
+ assert [e['feerate'] for e in feerates['estimates']] == [1000000] * 4
+ # max_fee_multiplier can't carry max_acceptable past the ceiling either.
+ assert feerates['max_acceptable'] == 1000000
+ # And what we're prepared to pay ourselves stays well under it.
+ assert feerates['opening'] <= 100000
+ assert feerates['splice'] <= 100000
+
+
def test_logging(node_factory):
# Since we redirect, node.start() will fail: do manually.
l1 = node_factory.get_node(options={'log-file': 'logfile'}, start=False)
### tests/test_splicing.py
@@ -1,6 +1,8 @@
from fixtures import * # noqa: F401,F403
from pyln.client import RpcError
+import os
import pytest
+import threading
import unittest
import time
from utils import (
@@ -47,6 +49,112 @@ def test_splice(node_factory, bitcoind):
assert l1.db_query("SELECT count(*) as c FROM channeltxs;")[0]['c'] == 0
+def _splice_to_inflight(l1, chan_id, amount=100000):
+ """Drive a splice as far as an inflight in the db, and return its txid."""
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
+ result = l1.rpc.splice_init(chan_id, amount, funds_result['psbt'])
+ result = l1.rpc.splice_update(chan_id, result['psbt'])
+ result = l1.rpc.splice_update(chan_id, result['psbt'])
+ assert result['commitments_secured'] is True
+ result = l1.rpc.signpsbt(result['psbt'])
+ result = l1.rpc.splice_signed(chan_id, result['signed_psbt'])
+ l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ return result['txid']
+
+
+@pytest.mark.openchannel('v1')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3',
+ "modifies database, which is assumed sqlite3")
+# -1 is how lightningd itself stored a u32 above INT_MAX (db_bind_int); the
+# positive form is what you get writing the same value by hand.
+@pytest.mark.parametrize("poison,repaired", [(4294967295, 1000000),
+ (-1, 1000000),
+ (2000000, 1000000),
+ (0, 253)])
+def test_splice_stored_feerate_repaired_on_upgrade(node_factory, bitcoind,
+ poison, repaired):
+ """An out-of-range stored funding feerate is repaired when we upgrade.
+
+ Nothing used to bound what got written to
+ channel_funding_inflights.funding_feerate, and json_add_channel then
+ asserted on it: the BOLT #2 25/24 RBF bump overflows a u32 above
+ UINT_MAX/25, and 0 tripped the assert right above it. Since plugins call
+ listpeerchannels at startup, a single bad row crash-looped the node with
+ no RPC left to repair it with, which is what the migration is for.
+ """
+ l1, l2 = node_factory.line_graph(2, fundamount=1000000,
+ wait_for_announce=True)
+ chan_id = l1.get_channel_id(l2)
+ _splice_to_inflight(l1, chan_id)
+
+ l1.stop()
+ l1.db_manip("UPDATE channel_funding_inflights"
+ " SET funding_feerate = {}".format(poison))
+
+ # Rewind past the two clamping migrations so they run again over the row
+ # we just planted, which is the upgrade an attacked node goes through.
+ # They are plain idempotent UPDATEs, so re-running them is safe.
+ l1.db_manip("UPDATE version SET version = version - 2")
+ l1.daemon.opts['database-upgrade'] = 'true'
+ l1.start()
+
+ assert l1.daemon.is_in_log(r'Updating database from version')
+
+ row = l1.db_query("SELECT funding_feerate AS f"
+ " FROM channel_funding_inflights;")[0]
+ assert row['f'] == repaired
+
+ # And the read path, which used to abort here, agrees.
+ chan = only_one(l1.rpc.listpeerchannels()['channels'])
+ assert chan['last_feerate'] == '{}perkw'.format(repaired)
+ assert chan['next_feerate'] == '{}perkw'.format(repaired * 25 // 24)
+
+
+@pytest.mark.openchannel('v1')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_splice_feerate_too_high(node_factory, bitcoind):
+ """We refuse a splice a peer proposes at an absurd feerate.
+
+ The fee comes out of the initiator's balance, so we gain nothing by
+ signing it; they lose the difference to a broken fee estimator of theirs.
+ Before this there was no upper bound on the accepter side at all.
+ """
+ l1, l2 = node_factory.line_graph(2, fundamount=1000000,
+ wait_for_announce=True,
+ opts={'allow_warning': True,
+ 'may_reconnect': True})
+ chan_id = l1.get_channel_id(l2)
+
+ # Both sides agree feerate_max is 15000 * max_fee_multiplier.
+ assert l2.rpc.feerates('perkw')['perkw']['max_acceptable'] == 150000
+
+ # force_feerate gets us past *our* check on what we're willing to pay,
+ # which leaves l2's bound as the thing under test.
+ funds_result = l1.rpc.fundpsbt("111722sat", 0, 0, excess_as_change=True)
+
+ # l2 refuses on receipt of splice_init, so the splice_ack l1 is waiting
+ # for never arrives: run it in a daemon thread so the test can proceed.
+ def _splice():
+ try:
+ # force_feerate isn't in the pyln-client wrapper, so call directly.
+ l1.rpc.call('splice_init',
+ {'channel_id': chan_id,
+ 'relative_amount': 100000,
+ 'initialpsbt': funds_result['psbt'],
+ # param_feerate reads a bare number as perkb, and
+ # the schema only allows a bare number here:
+ # 800000perkb == 200000perkw.
+ 'feerate_per_kw': 800000,
+ 'force_feerate': True})
+ except Exception:
+ pass
+
+ threading.Thread(target=_splice, daemon=True).start()
+
+ l2.daemon.wait_for_log(r'Splice feerate_perkw 200000 is above our maximum 150000')
+
+
@pytest.mark.openchannel('v1')
@pytest.mark.openchannel('v2')
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')Why this scored 72/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.