Skip to content

openingd: fail open_channel at receipt when both initial balances <= their reserve - #9480

Open
Amperstrand wants to merge 5 commits into
ElementsProject:masterfrom
Amperstrand:pr9475-open-channel-reserve-must
Open

openingd: fail open_channel at receipt when both initial balances <= their reserve#9480
Amperstrand wants to merge 5 commits into
ElementsProject:masterfrom
Amperstrand:pr9475-open-channel-reserve-must

Conversation

@Amperstrand

@Amperstrand Amperstrand commented Sep 5, 2026

Copy link
Copy Markdown

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, not a general
channel-establishment rule. 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 (#9475).

This projects the initial balances at open_channel receipt (funder
to_local = funding − push − base fee − 2×330 anchor outputs; accepter
to_remote = push) and fails 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).

Notes for reviewers:

  • The misplaced initial_commit_tx() check stays: it remains the
    authoritative backstop at funding_created, and covers the funder
    side's own construction.
  • 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 channel capacity with
    funding 100000sat, reserves 44000sat/44000sat, … channel capacity is
    9935sat, which is below 10000sat" on the reporter's 100k funding;
    43000sat passes), which blocks every shape that trips this check. I
    validated with a ~300-line BOLT8 wire peer (noise-XK initiator,
    spec-vector-pinned) driving the reporter's exact parameters instead:
    pre-fix accept_channel, post-fix rejection citing the projected
    balances (78778000msat / 20000000msat); boundary shifts
    86,100→77,092 at feerate 2000 and the static_remotekey (724-weight)
    path lands at 78,551/78,552, both exactly on the projection. The complete wire-peer source, per-cell results, and boundary math are in this gist: https://gist.github.com/Amperstrand/c2078688f93800cf7723cad2670c8044
  • Reserves between the two initial balances (only the accepter's
    balance below reserve) still lock in spendable-zero channels — that
    is spec-legal (only the MAY "considers channel_reserve_satoshis
    too large" would catch it); a follow-up could discuss a cap like
    lnd's 20%-of-capacity bound (lnwallet/reservation.go: channelCapacity / 5) or eclair's max-reserve-to-funding-ratio = 0.05.
reporter's exact params, pre- vs post-fix wire (v26.06.7) — expand
pre-fix  (100k funding, 20k push, 87k reserve, anchors, feerate 500):
         accept_channel sent — both initial balances (78,778 / 20,000) below the 87k reserve
post-fix: "kind": "wire_error", "data": "You gave bad parameters: Their channel reserve 87000sat
         is not exceeded by either initial balance (78778000msat, 20000000msat)"
boundary shift at feerate 2000 — anchors and static_remotekey paths both land on the BOLT3 projection — expand
c5-r77091  accepted=YES          c5-r77092  rejected (new check cites 77092000msat)
c5-r86091  rejected (new check)  c5-r86100  rejected (aggregate capacity check, unchanged)
c5na-r78551 accepted=YES (724-weight path)  c5na-r78552 rejected (cites 78552000msat)

Fixes #9475

@Andezion Andezion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think, do we need a test for this change? contrib/pyln-proto/pyln/proto/wire.py already implements a BOLT8 wire client and is already used for raw-wire tests in tests/test_connection.py (test_connect_basic), so a from-scratch ~~300-line wire peer isntt really necessary

Comment thread openingd/openingd.c Outdated
*funder_pay = AMOUNT_MSAT(0);

return amount_msat_greater_sat(*funder_pay, their_reserve)
|| amount_msat_greater_sat(*accepter_pay, their_reserve);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the two return true paths (anchor-fee overflow, funding -> msat overflow), *funder_pay/*accepter_pay are left uninitialized. Its not currently exploitable - the caller only reads them inside the if (!initial_balances_exceed_reserve(. . .)) branch, which is never taken when the function returns true - but its a silent invariant a future refactor could break

Comment thread openingd/openingd.c Outdated
* 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in common/initial_commit_tx.h we have try_subtract_fee(REMOTE, REMOTE, base_fee, &funder_pay, &accepter_pay) (fundee is always LOCAL, peer/funder always REMOTE here). Maybe we can use it instead?

@morehouse

Copy link
Copy Markdown
Contributor

We recently found a similar spec compliance issue in #9491. IMO it would also be worth adding that missing check in this PR.

Amperstrand added 2 commits September 9, 2026 21:28
…their reserve

BOLT ElementsProject#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 ElementsProject#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: ElementsProject#9475
Fixes: ElementsProject#9475
Signed-off-by: Amperstrand <amperstrand@localhost>
…the initial commitment fee

BOLT ElementsProject#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 (ElementsProject#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 ElementsProject#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 ElementsProject#9491, one-msat fee boundaries on both the
anchors and static_remotekey weight paths, and the reserve boundary
from ElementsProject#9475. All three reject on stock (and the fee pair on the
reserve-only parent) and pass here.

Changelog-Fixes: ElementsProject#9491
Fixes: ElementsProject#9491
Signed-off-by: Amperstrand <amperstrand@localhost>
@Amperstrand
Amperstrand force-pushed the pr9475-open-channel-reserve-must branch from 2b2f5f2 to b563df6 Compare September 9, 2026 19:28
Amperstrand added 3 commits September 10, 2026 10:59
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 <amperstrand@localhost>
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 <amperstrand@localhost>
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 <amperstrand@localhost>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Failure to reject absurdly high channel reserves

3 participants