From a842468ff26ec2c6383b1c41a1abadcb99a44acf Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Sat, 5 Sep 2026 22:35:22 +0200 Subject: [PATCH 1/5] openingd: fail open_channel at receipt when both initial balances <= their reserve BOLT #2 requires the receiving node to fail the channel if both to_local and to_remote of the initial commitment transaction are <= the opener's channel_reserve_satoshis (a receiving-node MUST under open_channel receipt handling). CLN implements the comparison, but in initial_commit_tx() (common/initial_commit_tx.c, whose FIXME says it should be in #2), so it only fires at funding_created receipt -- after accept_channel has already gone out. Project the initial balances at open_channel receipt (funder to_local = funding - push - base fee - 2x330 anchor outputs; accepter to_remote = push) and fail the negotiation before accept_channel is sent, using the same fee math as initial_commit_tx() (commit_tx_base_fee + the 660-sat anchor correction). The misplaced check stays as the authoritative backstop at funding_created. An in-suite test would need a raw-wire opener: a stock fundchannel reserve is pre-checked with the reserve doubled ('Not opening because if they used the same setting as us ... below 10000sat'), which blocks every shape that trips this check. Validated with a BOLT8 wire peer driving the reporter's exact parameters (100k funding, 20k push, 87k reserve: pre-fix accept_channel, post-fix rejection citing the projected balances 78778000msat / 20000000msat). Changelog-Fixes: #9475 Fixes: #9475 Signed-off-by: Amperstrand --- openingd/openingd.c | 65 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/openingd/openingd.c b/openingd/openingd.c index a0585c6cfd9d..1948a1fa5a71 100644 --- a/openingd/openingd.c +++ b/openingd/openingd.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -827,6 +828,38 @@ static u8 *funder_channel_complete(struct state *state) } /*~ The peer sent us an `open_channel`, that means we're the fundee. */ +/* Projected initial commitment balances at open_channel receipt: the + * funder's to_local is funding - push - fee (BOLT #3: the base fee and + * the two 330-sat anchor outputs come off the funder); the accepter's + * to_remote is push. Returns false and fills the (saturating) balances + * if NEITHER exceeds their channel_reserve_satoshis. */ +static bool initial_balances_exceed_reserve(struct amount_sat funding_sats, + struct amount_msat push_msat, + u32 feerate_per_kw, + struct amount_sat their_reserve, + bool anchors_zero_fee, + struct amount_msat *funder_pay, + struct amount_msat *accepter_pay) +{ + struct amount_sat base_fee; + + base_fee = commit_tx_base_fee(feerate_per_kw, 0, false, + anchors_zero_fee); + if (anchors_zero_fee + && !amount_sat_add(&base_fee, base_fee, AMOUNT_SAT(660))) + return true; + + *accepter_pay = push_msat; + if (!amount_sat_to_msat(funder_pay, funding_sats)) + return true; + if (!amount_msat_sub(funder_pay, *funder_pay, push_msat) + || !amount_msat_sub_sat(funder_pay, *funder_pay, base_fee)) + *funder_pay = AMOUNT_MSAT(0); + + return amount_msat_greater_sat(*funder_pay, their_reserve) + || amount_msat_greater_sat(*accepter_pay, their_reserve); +} + static u8 *fundee_channel(struct state *state, const u8 *open_channel_msg) { struct channel_id id_in; @@ -1007,6 +1040,38 @@ static u8 *fundee_channel(struct state *state, const u8 *open_channel_msg) return NULL; } + /* BOLT #2: + * + * The receiving node MUST fail the channel if: + *... + * - both `to_local` and `to_remote` amounts for the initial + * commitment transaction are less than or equal to + * `channel_reserve_satoshis`. + */ + { + struct amount_msat funder_pay, accepter_pay; + + if (!initial_balances_exceed_reserve(state->funding_sats, + state->push_msat, + state->feerate_per_kw, + state->remoteconf.channel_reserve, + channel_type_has( + state->channel_type, + OPT_ANCHORS_ZERO_FEE_HTLC_TX), + &funder_pay, + &accepter_pay)) { + negotiation_failed(state, + "Their channel reserve %s is not " + "exceeded by either initial " + "balance (%s, %s)", + fmt_amount_sat(tmpctx, + state->remoteconf.channel_reserve), + fmt_amount_msat(tmpctx, funder_pay), + fmt_amount_msat(tmpctx, accepter_pay)); + return NULL; + } + } + /* Check with lightningd that we can accept this? In particular, * if we have an existing channel, we don't support it. */ msg = towire_openingd_got_offer(NULL, From b563df67994e0f9b3d12b707b70dee2e524ab107 Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Wed, 9 Sep 2026 21:28:28 +0200 Subject: [PATCH 2/5] openingd: fail open_channel at receipt when the funder cannot afford the initial commitment fee BOLT #2 requires the receiving node to fail the channel when the funder's amount for the initial commitment transaction is not sufficient for full fee payment (#9491). CLN implements the rule in initial_commit_tx(), so like the reserve check it only fires at funding_created receipt, after accept_channel has gone out -- and a full push (push_msat = funding_satoshis * 1000) sails past the reserve projection added for #9475, because the accepter's balance exceeds the reserve while the funder is left at zero. Fold the check into the same open_channel receipt projection: deduct push first, then try_subtract_fee(REMOTE, REMOTE, ...) for the base fee (with the 660-sat anchor correction), failing with the backstop's exact wording when the funder comes up short. The projected balances are now filled on every path, and the fee deduction reuses try_subtract_fee() from common/initial_commit_tx.h instead of hand-rolled saturating arithmetic. Adds in-suite raw-wire tests on the pyln-proto LightningConnection (same pattern as test_open_channel_funding_above_max_supply): the full-push rejection from #9491, one-msat fee boundaries on both the anchors and static_remotekey weight paths, and the reserve boundary from #9475. All three reject on stock (and the fee pair on the reserve-only parent) and pass here. Changelog-Fixes: #9491 Fixes: #9491 Signed-off-by: Amperstrand --- openingd/openingd.c | 109 ++++++++++++++++++------ tests/test_connection.py | 176 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 253 insertions(+), 32 deletions(-) diff --git a/openingd/openingd.c b/openingd/openingd.c index 1948a1fa5a71..845d1359ac02 100644 --- a/openingd/openingd.c +++ b/openingd/openingd.c @@ -829,35 +829,80 @@ static u8 *funder_channel_complete(struct state *state) /*~ The peer sent us an `open_channel`, that means we're the fundee. */ /* Projected initial commitment balances at open_channel receipt: the - * funder's to_local is funding - push - fee (BOLT #3: the base fee and - * the two 330-sat anchor outputs come off the funder); the accepter's - * to_remote is push. Returns false and fills the (saturating) balances - * if NEITHER exceeds their channel_reserve_satoshis. */ -static bool initial_balances_exceed_reserve(struct amount_sat funding_sats, - struct amount_msat push_msat, - u32 feerate_per_kw, - struct amount_sat their_reserve, - bool anchors_zero_fee, - struct amount_msat *funder_pay, - struct amount_msat *accepter_pay) + * funder's to_local is funding - push - fee (BOLT #3: the base fee and, + * with `option_anchors`, the two 330-sat anchor outputs come off the + * funder); the accepter's to_remote is push. */ +enum initial_balance_violation { + INITIAL_BALANCES_OK, + FUNDER_CANNOT_AFFORD_FEE, + NO_BALANCE_EXCEEDS_RESERVE, +}; + +/* Check the two BOLT #2 receiving-node MUSTs that initial_commit_tx() + * only enforces at funding_created, after accept_channel has gone out. + * Returns INITIAL_BALANCES_OK, or the violated MUST. Every path fills + * the out-params (with the projected post-fee balances) so the caller + * can print them. */ +static enum initial_balance_violation initial_balances_check(struct amount_sat funding_sats, + struct amount_msat push_msat, + u32 feerate_per_kw, + struct amount_sat their_reserve, + bool anchors_zero_fee, + struct amount_msat *funder_pay, + struct amount_msat *accepter_pay) { struct amount_sat base_fee; + *funder_pay = AMOUNT_MSAT(0); + *accepter_pay = push_msat; + base_fee = commit_tx_base_fee(feerate_per_kw, 0, false, anchors_zero_fee); if (anchors_zero_fee && !amount_sat_add(&base_fee, base_fee, AMOUNT_SAT(660))) - return true; + /* Absurd feerate (fee overflow): the funding_created + * backstop in initial_commit_tx() ("Funder cannot afford + * anchor outputs") decides; nothing to flag at receipt. */ + return INITIAL_BALANCES_OK; - *accepter_pay = push_msat; if (!amount_sat_to_msat(funder_pay, funding_sats)) - return true; - if (!amount_msat_sub(funder_pay, *funder_pay, push_msat) - || !amount_msat_sub_sat(funder_pay, *funder_pay, base_fee)) + /* Absurd funding (already capped by max_channel_funding): + * the funding_created backstop decides. */ + return INITIAL_BALANCES_OK; + + /* The funder's to_local starts at funding - push (BOLT #2 caps + * push at funding, so this saturates to zero at worst). */ + if (!amount_msat_sub(funder_pay, *funder_pay, push_msat)) *funder_pay = AMOUNT_MSAT(0); - return amount_msat_greater_sat(*funder_pay, their_reserve) - || amount_msat_greater_sat(*accepter_pay, their_reserve); + /* BOLT #2: + * + * The receiving node MUST fail the channel if: + *... + * - the funder's amount for the initial commitment transaction + * is not sufficient for full fee payment. + */ + /* We are the fundee (LOCAL), the peer is the funder (REMOTE): + * try_subtract_fee(REMOTE, REMOTE, ...) takes the fee off the + * funder's balance, saturating to 0 and returning false when it + * cannot cover it in full. */ + if (!try_subtract_fee(REMOTE, REMOTE, base_fee, + funder_pay, accepter_pay)) + return FUNDER_CANNOT_AFFORD_FEE; + + /* BOLT #2: + * + * The receiving node MUST fail the channel if: + *... + * - both `to_local` and `to_remote` amounts for the initial + * commitment transaction are less than or equal to + * `channel_reserve_satoshis`. + */ + if (!amount_msat_greater_sat(*funder_pay, their_reserve) + && !amount_msat_greater_sat(*accepter_pay, their_reserve)) + return NO_BALANCE_EXCEEDS_RESERVE; + + return INITIAL_BALANCES_OK; } static u8 *fundee_channel(struct state *state, const u8 *open_channel_msg) @@ -1044,6 +1089,8 @@ static u8 *fundee_channel(struct state *state, const u8 *open_channel_msg) * * The receiving node MUST fail the channel if: *... + * - the funder's amount for the initial commitment transaction + * is not sufficient for full fee payment. * - both `to_local` and `to_remote` amounts for the initial * commitment transaction are less than or equal to * `channel_reserve_satoshis`. @@ -1051,15 +1098,21 @@ static u8 *fundee_channel(struct state *state, const u8 *open_channel_msg) { struct amount_msat funder_pay, accepter_pay; - if (!initial_balances_exceed_reserve(state->funding_sats, - state->push_msat, - state->feerate_per_kw, - state->remoteconf.channel_reserve, - channel_type_has( - state->channel_type, - OPT_ANCHORS_ZERO_FEE_HTLC_TX), - &funder_pay, - &accepter_pay)) { + switch (initial_balances_check(state->funding_sats, + state->push_msat, + state->feerate_per_kw, + state->remoteconf.channel_reserve, + channel_type_has( + state->channel_type, + OPT_ANCHORS_ZERO_FEE_HTLC_TX), + &funder_pay, + &accepter_pay)) { + case FUNDER_CANNOT_AFFORD_FEE: + negotiation_failed(state, + "Funder cannot afford fee on initial " + "commitment transaction"); + return NULL; + case NO_BALANCE_EXCEEDS_RESERVE: negotiation_failed(state, "Their channel reserve %s is not " "exceeded by either initial " @@ -1069,6 +1122,8 @@ static u8 *fundee_channel(struct state *state, const u8 *open_channel_msg) fmt_amount_msat(tmpctx, funder_pay), fmt_amount_msat(tmpctx, accepter_pay)); return NULL; + case INITIAL_BALANCES_OK: + break; } } diff --git a/tests/test_connection.py b/tests/test_connection.py index 4b07bda708c9..59c4134d1a06 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -5040,7 +5040,7 @@ def raw_peer_connect(node): def send_open_channel(lconn, chain_hash, temp_chan_id, funding_sat, push_msat, - feerate_per_kw, channel_type): + feerate_per_kw, channel_type, channel_reserve=10000): # Six distinct valid points; they only have to parse. keys = [wire.PrivateKey(bytes([i + 1] * 32)).public_key().serializeCompressed() for i in range(6)] @@ -5052,7 +5052,7 @@ def send_open_channel(lconn, chain_hash, temp_chan_id, funding_sat, push_msat, msg += struct.pack('>Q', push_msat) # push_msat msg += struct.pack('>Q', 546) # dust_limit_satoshis msg += struct.pack('>Q', 0xFFFFFFFFFFFF) # max_htlc_value_in_flight_msat - msg += struct.pack('>Q', 10000) # channel_reserve_satoshis + msg += struct.pack('>Q', channel_reserve) # channel_reserve_satoshis msg += struct.pack('>Q', 0) # htlc_minimum_msat msg += struct.pack('>I', feerate_per_kw) # feerate_per_kw msg += struct.pack('>H', 144) # to_self_delay @@ -5065,16 +5065,40 @@ def send_open_channel(lconn, chain_hash, temp_chan_id, funding_sat, push_msat, lconn.send_message(msg) -def read_channel_reply(lconn): - """Read past gossip chatter to openingd's answer to our open_channel.""" +def read_channel_reply_msg(lconn): + """Read past gossip chatter to openingd's answer to our open_channel. + + Returns the message type and the raw message.""" for _ in range(20): msg = lconn.read_message() mtype = int.from_bytes(msg[0:2], 'big') if mtype in (WIRE_ACCEPT_CHANNEL, WIRE_WARNING, WIRE_ERROR): - return mtype + return mtype, msg raise AssertionError("no reply to open_channel") +def read_channel_reply(lconn): + """Read past gossip chatter to openingd's answer to our open_channel.""" + return read_channel_reply_msg(lconn)[0] + + +def error_data(msg): + """Human-readable data of a WIRE_ERROR/WIRE_WARNING reply.""" + if int.from_bytes(msg[0:2], 'big') == WIRE_ERROR: + return msg[36:] + return msg[34:] + + +def initial_commitment_fee_sat(feerate_per_kw, anchors): + """Sats the funder pays for the initial commitment transaction + (BOLT #3): base fee at the 1124 (anchors) or 724 base weight, plus + the two 330-sat anchor outputs when `option_anchors` applies.""" + fee = (feerate_per_kw * (1124 if anchors else 724) + 999) // 1000 + if anchors: + fee += 660 + return fee + + def send_funding_created(lconn, temp_chan_id): """Drive the open to the point where we build the commitment transaction. @@ -5136,3 +5160,145 @@ def test_open_channel_funding_above_max_supply(node_factory, bitcoind): funding_sat, push_msat) assert l1.rpc.getinfo()['id'] == l1.info['id'] + + +@pytest.mark.openchannel('v1') +def test_open_channel_funder_cannot_afford_fee(node_factory, bitcoind): + """A funder left short of the commitment fee must be rejected. + + BOLT 2: the receiving node MUST fail the channel if the funder's + amount for the initial commitment transaction is not sufficient + for full fee payment. CLN only noticed in initial_commit_tx(), + after accept_channel has gone out. + """ + l1 = node_factory.get_node() + + chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + # Use the node's own opening feerate, so we're inside its accepted range. + feerate = l1.rpc.feerates('perkw')['perkw']['opening'] + funding_sat = 16777216 + + # The funder pays the commitment fee out of its own balance, so pushing the + # balance away leaves it with nothing to pay from. + push_msat = funding_sat * 1000 + + lconn, channel_type = raw_peer_connect(l1) + temp_chan_id = os.urandom(32) + send_open_channel(lconn, chain_hash, temp_chan_id, funding_sat, + push_msat, feerate, channel_type) + + mtype, msg = read_channel_reply_msg(lconn) + assert mtype in (WIRE_WARNING, WIRE_ERROR), \ + "funder left with {} sat to pay the commitment fee was not rejected (got msgtype {})".format( + funding_sat - push_msat // 1000, mtype) + assert b'Funder cannot afford fee' in error_data(msg) + + assert not l1.daemon.is_in_log('Owning subdaemon openingd died') + + +@pytest.mark.openchannel('v1') +def test_open_channel_initial_fee_boundary(node_factory, bitcoind): + """One-msat boundary around the funder affording the fee exactly. + + BOLT #3 fee payment: the funder's to_local is funding - push - + base fee - 660 sats of anchors (option_anchors) or base fee alone + (static_remotekey). Affording the fee exactly is legal; one msat + short is a MUST-fail. + """ + l1 = node_factory.get_node() + + chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + feerate = l1.rpc.feerates('perkw')['perkw']['opening'] + funding_sat = 100000 + + for anchors in (True, False): + fee = initial_commitment_fee_sat(feerate, anchors) + exact_push = (funding_sat - fee) * 1000 + + for push_msat, rejected in ((exact_push, False), + (exact_push + 1, True)): + lconn, node_ctype = raw_peer_connect(l1) + if anchors: + channel_type = node_ctype + else: + channel_type = featurebits(OPT_STATIC_REMOTEKEY) + temp_chan_id = os.urandom(32) + send_open_channel(lconn, chain_hash, temp_chan_id, funding_sat, + push_msat, feerate, channel_type) + + mtype, msg = read_channel_reply_msg(lconn) + if rejected: + assert mtype in (WIRE_WARNING, WIRE_ERROR), \ + "push {} msat over a {} sat fee was not rejected (got msgtype {})".format( + push_msat, fee, mtype) + assert b'Funder cannot afford fee' in error_data(msg) + else: + assert mtype == WIRE_ACCEPT_CHANNEL, \ + "push {} msat over a {} sat fee should be affordable (got msgtype {})".format( + push_msat, fee, mtype) + + # Accept cells abandon the negotiation on purpose: openingd exiting + # at the dropped connection is normal, assert only on crashes. + assert not l1.daemon.is_in_log('assertion failed') + assert not l1.daemon.is_in_log('FATAL SIGNAL') + + +@pytest.mark.openchannel('v1') +def test_open_channel_reserve_too_high(node_factory, bitcoind): + """Both initial balances at or below channel_reserve_satoshis must + be rejected at open_channel receipt. + + BOLT 2: the receiving node MUST fail the channel if both to_local + and to_remote of the initial commitment transaction are less than + or equal to the opener's channel_reserve_satoshis. CLN only + noticed in initial_commit_tx(), after accept_channel has gone out. + """ + l1 = node_factory.get_node() + + chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + feerate = l1.rpc.feerates('perkw')['perkw']['opening'] + funding_sat = 100000 + push_msat = 20000000 + + fee = initial_commitment_fee_sat(feerate, anchors=True) + # Funder's projected to_local after fee; the accepter's to_remote is push. + funder_sat = funding_sat - push_msat // 1000 - fee + + for reserve, rejected in ((funder_sat, True), + (funder_sat - 1, False)): + lconn, channel_type = raw_peer_connect(l1) + temp_chan_id = os.urandom(32) + send_open_channel(lconn, chain_hash, temp_chan_id, funding_sat, + push_msat, feerate, channel_type, channel_reserve=reserve) + + mtype, msg = read_channel_reply_msg(lconn) + if rejected: + # funder == reserve and accepter (push) << reserve: both at + # or below the reserve. + assert mtype in (WIRE_WARNING, WIRE_ERROR), \ + "reserve {} sat over funder balance {} sat was not rejected (got msgtype {})".format( + reserve, funder_sat, mtype) + assert b'not exceeded by either initial balance' in error_data(msg) + else: + assert mtype == WIRE_ACCEPT_CHANNEL, \ + "reserve {} sat below funder balance {} sat should be accepted (got msgtype {})".format( + reserve, funder_sat, mtype) + + # The static_remotekey (724-weight) path shifts the boundary by the + # fee difference; it must land on its own projection, not the + # anchors one. + fee = initial_commitment_fee_sat(feerate, anchors=False) + funder_sat = funding_sat - push_msat // 1000 - fee + lconn, _ = raw_peer_connect(l1) + send_open_channel(lconn, chain_hash, os.urandom(32), funding_sat, + push_msat, feerate, featurebits(OPT_STATIC_REMOTEKEY), + channel_reserve=funder_sat) + mtype, msg = read_channel_reply_msg(lconn) + assert mtype in (WIRE_WARNING, WIRE_ERROR), \ + "static_remotekey reserve boundary was not rejected (got msgtype {})".format(mtype) + assert b'not exceeded by either initial balance' in error_data(msg) + + # Accept cells abandon the negotiation on purpose: openingd exiting + # at the dropped connection is normal, assert only on crashes. + assert not l1.daemon.is_in_log('assertion failed') + assert not l1.daemon.is_in_log('FATAL SIGNAL') From d732d395ae3844794ddff52320a19b807bbbb01c Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Thu, 10 Sep 2026 10:59:04 +0200 Subject: [PATCH 3/5] tests: match amount_tx_fee truncation and pin the reserve message order The fee helper used ceiling division while amount_tx_fee() truncates (fee_per_kw * weight / 1000), so it is one sat over whenever the product is not a multiple of 1000 -- the one-msat boundary cells go stale on any runner whose opening feerate does not divide evenly (a boundary-matrix sweep against the built node caught it at feerate 1875: 2107.5 -> 2107). Also assert the projected balances in order in the reserve test's error message, so a swap of the two amounts cannot pass silently (this was the one survivor of a seven-mutation kill matrix). Changelog-None: test-only Signed-off-by: Amperstrand --- tests/test_connection.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_connection.py b/tests/test_connection.py index 59c4134d1a06..8cdf414118a0 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -5092,8 +5092,11 @@ def error_data(msg): def initial_commitment_fee_sat(feerate_per_kw, anchors): """Sats the funder pays for the initial commitment transaction (BOLT #3): base fee at the 1124 (anchors) or 724 base weight, plus - the two 330-sat anchor outputs when `option_anchors` applies.""" - fee = (feerate_per_kw * (1124 if anchors else 724) + 999) // 1000 + the two 330-sat anchor outputs when `option_anchors` applies. + Truncating division, like amount_tx_fee() (a ceiling here is one + sat over whenever feerate*weight isn't a multiple of 1000, and the + boundary cells go stale).""" + fee = feerate_per_kw * (1124 if anchors else 724) // 1000 if anchors: fee += 660 return fee @@ -5278,7 +5281,12 @@ def test_open_channel_reserve_too_high(node_factory, bitcoind): assert mtype in (WIRE_WARNING, WIRE_ERROR), \ "reserve {} sat over funder balance {} sat was not rejected (got msgtype {})".format( reserve, funder_sat, mtype) - assert b'not exceeded by either initial balance' in error_data(msg) + # The message cites the projected balances in order — + # pin them so a swap of the two cannot pass silently. + funder_msat = funding_sat * 1000 - push_msat - fee * 1000 + assert ('not exceeded by either initial balance ' + '({}msat, {}msat)'.format(funder_msat, push_msat) + ).encode() in error_data(msg) else: assert mtype == WIRE_ACCEPT_CHANNEL, \ "reserve {} sat below funder balance {} sat should be accepted (got msgtype {})".format( From 3c6e5195f5bb64e7587acba6ef90ba2267dc4c71 Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Thu, 10 Sep 2026 21:48:35 +0200 Subject: [PATCH 4/5] tests: skip the fee-boundary tests on elements networks CI runs the suite with TEST_NETWORK=liquid-regtest too, where commit_tx_base_fee() carries elements_tx_overhead() (610 extra weight units on the anchors shape, 470 on static_remotekey) -- the tests' boundary arithmetic is Bitcoin-weight only, so the exact-afford cells would mispredict by kilosats and fail spuriously. Same guard the suite already uses for fee math ("Fee computation and limits are network specific"); the full-push rejection test stays unguarded (fee-independent) so the new checks still run on the liquid arm. Changelog-None: test-only Signed-off-by: Amperstrand --- tests/test_connection.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_connection.py b/tests/test_connection.py index 8cdf414118a0..19a0aff26840 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -5199,6 +5199,7 @@ def test_open_channel_funder_cannot_afford_fee(node_factory, bitcoind): assert not l1.daemon.is_in_log('Owning subdaemon openingd died') +@unittest.skipIf(TEST_NETWORK != 'regtest', "Fee computation and limits are network specific") @pytest.mark.openchannel('v1') def test_open_channel_initial_fee_boundary(node_factory, bitcoind): """One-msat boundary around the funder affording the fee exactly. @@ -5246,6 +5247,7 @@ def test_open_channel_initial_fee_boundary(node_factory, bitcoind): assert not l1.daemon.is_in_log('FATAL SIGNAL') +@unittest.skipIf(TEST_NETWORK != 'regtest', "Fee computation and limits are network specific") @pytest.mark.openchannel('v1') def test_open_channel_reserve_too_high(node_factory, bitcoind): """Both initial balances at or below channel_reserve_satoshis must From e5abc004d9b6c6a5aa351847b3157e3219ad7d1d Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Thu, 10 Sep 2026 22:25:40 +0200 Subject: [PATCH 5/5] tests: carry the network-correct chain_hash in the raw-wire helpers The raw-wire open_channel tests built chain_hash as getblockhash(0) reversed -- right on bitcoin networks, wrong on liquid-regtest, where CLN's elements chainparams store the genesis hash in display byte order (bitcoin/chainparams.c) and the node answers "Unknown chain-hash". test_open_channel_funding_above_max_supply passed there only vacuously (any rejection satisfies it); the new full-push test needs the right hash AND the specific error, which is what surfaced this. wire_chain_hash() picks the form per network, and all four raw-wire call sites use it -- the receipt checks now run on the liquid arm for real (validated against elementsd 23.2.1, the CI pin, and the just-released 23.3.4). Changelog-None: test-only Signed-off-by: Amperstrand --- tests/test_connection.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_connection.py b/tests/test_connection.py index 19a0aff26840..2e3c670a0bf2 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -5008,6 +5008,20 @@ def feature_offered(bits, b): return False +def wire_chain_hash(bitcoind): + """The chain_hash an open_channel must carry for THIS network. + + CLN's bitcoin chainparams store the genesis hash in internal byte + order and its elements ones in display order (bitcoin/chainparams.c), + so the wire form of getblockhash(0) flips per family: reversed for + bitcoin networks, as-is on liquid-regtest. + """ + ch = bytes.fromhex(bitcoind.rpc.getblockhash(0)) + if TEST_NETWORK == 'liquid-regtest': + return ch + return ch[::-1] + + def raw_peer_connect(node): """Handshake to node as a raw peer, echoing back its own features. @@ -5125,7 +5139,7 @@ def test_open_channel_funding_above_max_supply(node_factory, bitcoind): """ l1 = node_factory.get_node() - chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + chain_hash = wire_chain_hash(bitcoind) # Use the node's own opening feerate, so we're inside its accepted range. feerate = l1.rpc.feerates('perkw')['perkw']['opening'] @@ -5176,7 +5190,7 @@ def test_open_channel_funder_cannot_afford_fee(node_factory, bitcoind): """ l1 = node_factory.get_node() - chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + chain_hash = wire_chain_hash(bitcoind) # Use the node's own opening feerate, so we're inside its accepted range. feerate = l1.rpc.feerates('perkw')['perkw']['opening'] funding_sat = 16777216 @@ -5211,7 +5225,7 @@ def test_open_channel_initial_fee_boundary(node_factory, bitcoind): """ l1 = node_factory.get_node() - chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + chain_hash = wire_chain_hash(bitcoind) feerate = l1.rpc.feerates('perkw')['perkw']['opening'] funding_sat = 100000 @@ -5260,7 +5274,7 @@ def test_open_channel_reserve_too_high(node_factory, bitcoind): """ l1 = node_factory.get_node() - chain_hash = bytes.fromhex(bitcoind.rpc.getblockhash(0))[::-1] + chain_hash = wire_chain_hash(bitcoind) feerate = l1.rpc.feerates('perkw')['perkw']['opening'] funding_sat = 100000 push_msat = 20000000