Skip to content

feerates: bound peer-supplied and stored values (v26.06.7 security, 1/7) - #9507

Merged
nGoline merged 8 commits into
ElementsProject:masterfrom
nGoline:port-26.06.7-feerates
Sep 15, 2026
Merged

nGoline merged 8 commits into
ElementsProject:masterfrom
nGoline:port-26.06.7-feerates

Conversation

@nGoline

@nGoline nGoline commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

This is the first of seven PRs forward-porting the v26.06.7 security point release onto master. v26.06.7 is publicly a security release, and these are forward-ports.

Feerates reaching us from three directions are now bounded: what a peer proposes, what the Bitcoin backend's estimatefees reports, and what an older database already stored.

8 commits:

  • lightningd: Target a lower closing fee
  • closingd: Add a sanity check for feerate
  • lightningd: clamp estimatefees results to a sanity ceiling
  • channeld: split the feerate we'll pay from the one we'll accept
  • dualopend: bound the feerates a peer opens at
  • wallet: clamp absurd inflight funding feerates on upgrade
  • lightningd: don't assert on funding feerates read from the db
  • tests: regression coverage for out-of-range feerates

Eleven Changelog- entries, six Fixed and five Changed. Worth calling out in particular:

  • --ignore-fee-limits and setchannel's ignorefeelimits still drop our policy bounds, but no longer accept a feerate above 1000000perkw (4000 sat/vB).
  • We no longer propose a feerate above 100000perkw (400 sat/vB) of our own accord.
  • feerates reports max_acceptable as the ceiling rather than 4294967295 when no estimates are available.
  • listpeerchannels.next_feerate becomes optional.

For reviewers

The DB migration is the part to look at. wallet: clamp absurd inflight funding feerates on upgrade is the only commit in the whole 54-commit series that touches wallet/migrations.c. Master has added four migrations since the release line's merge base, so the two clamps are appended to the end of master's current table rather than inserted at their release-line position. They sit inside the open /* ^v26.09 */ block, which is the judgement call worth a second opinion. Both are idempotent UPDATEs with no revert.

This PR gates four others. G2, G3, G5 and G7 each touch files this one touches. G4 and G6 are independent of it.

Two commits here also carry documentation wording that the binary release deliberately held back (doc/schemas/setchannel.json, doc/schemas/listpeerchannels.json, doc/lightningd-config.5.md, doc/getting-started/getting-started/configuration.md, plus the generated contrib/msggen/msggen/schema.json). Master has never had either wording, so this is a choice of which text the ported commits carry, not a later revert. The restored text explains that reaching the ceiling means a broken fee estimator rather than a busy mempool, which is the more useful message for anyone who hits it.

Not in this series

502b664d9 common: check value overflow for forward amounts is not ported: master already has the fix as 96f026ecc. The release's test is about 15 lines larger than master's. Reconciling that is a separate follow-up.

ddustin and others added 3 commits September 14, 2026 14:09
This saves sats in certain scenarios by preferring a lower closing fee.

Changelog-Changed: lightningd: the maximum fee accepted for a mutual close is now bounded by the unilateral-close feerate.
(cherry picked from commit fc646ea)
As a final check before sending any signed mutal close, we confirm the fee remains below our max fee to accept.

Changelog-None

(cherry picked from commit 0aae0d5)
Nothing bounded the feerates we accept from the `estimatefees` plugin:
`parse_feerate_ranges` only checks that block counts ascend and that
rates do not increase with block count, and the conversion loop only
applies a floor.  A broken or misbehaving fee source could therefore
push an arbitrary u32 into every downstream feerate calculation.

Clamp both the reported floor and each individual rate to
FEERATE_CEILING once they have been converted to perkw, and log loudly
when we do.  We clamp rather than reject because `bitcoin_plugin_error`
is fatal(), and because discarding the estimate entirely is worse than
capping it: with no rates at all `feerate_max` falls into its "unknown"
path and returns UINT_MAX.

The ceiling is deliberately not a policy limit.  It sits at 4000 sat/vB,
several times the highest the chain has ever seen, so that it only ever
fires on a broken fee source.  Picking a tighter bound would mean
underestimating during genuine congestion, which is exactly when our
unilateral close and HTLC timeout transactions need to confirm.

The conversion has to be widened for the ceiling to see anything at all.
feerate_from_style() computed (feerate + 3) / 4 on a u32, so the top
three perkb values wrapped to 0 or 1 perkw, slipped under the ceiling,
and were then raised to the floor, hiding the whole thing.  That is also
the more dangerous direction, since it underpays our own unilateral
close, and it applies equally to operator input like
`feerate="4294967295perkb"`.  feerate_to_style() right below already
widened for the same reason; the asymmetry was an oversight.

Changelog-Fixed: lightningd: feerate estimates from the Bitcoin backend are now clamped to a sanity ceiling, so a misbehaving `estimatefees` plugin cannot feed absurd feerates into the rest of the daemon.
Changelog-Fixed: lightningd: a feerate given in perkb near the top of the 32-bit range no longer wraps to a near-zero perkw feerate.
(cherry picked from commit 2d80c3b)
@nGoline
nGoline requested a review from cdecker as a code owner September 14, 2026 18:14
cdecker added 5 commits September 15, 2026 08:40
The previous commit clamped our own fee source on the way in.  This is
the counterpart for the other side of the channel: our peer's estimator
can be just as broken as ours, and we are the only one in a position to
notice before their money is gone.

`splice_accepter` only rejected a remote `funding_feerate_perkw` below
feerate_min; there was no upper bound at all.  The downstream safeguard
in check_balances only inspects the accepter's own fee contribution, and
since accepter_relative is 0 our weight is nil, so that check never
fires.  A peer whose estimator has gone haywire can therefore drive a
splice at an arbitrary feerate and we will cheerfully sign it, even
though the fee comes out of their channel balance.  Refusing costs us
nothing and saves them from committing their funds to a transaction they
did not mean to pay for.

Reject anything above feerate_max, and apply the same bound in
handle_splice_init so that we extend the same courtesy in reverse: if
our own estimator is the broken one, we catch it before we ask a peer to
sign for it.  handle_splice_init honours `splice_force_feerate` there,
as check_balances already does: that flag is the user saying they meant
it, and a policy limit should not override it.

feerate_max also needs a bound of its own, since it was derived purely
from the estimates times config.max_fee_multiplier.  But a single bound
cannot serve both directions, because feerate_max gates what we accept
in `update_fee` *and* what we propose ourselves.  Set for the accept
side it would have us paying absurd fees; set for the propose side it
would pin our commitment feerate below what a congested chain demands,
which is precisely when it has to confirm.  So there are now two:

 - FEERATE_CEILING (1000000 perkw, 4000 sat/vB) is the most we let a
   peer drive us to.  Their estimator being broken is not by itself
   worth dropping a channel over, so this only has to exclude the
   absurd.
 - MAX_OUR_FEERATE_PER_KW (100000 perkw, 400 sat/vB) is the most we will
   pay: roughly 0.011 BTC for a bare anchor commitment.  Declining to
   propose a feerate costs us nothing, so we can afford to be strict.
   our_feerate_max() applies it wherever the money is ours:
   default_feerate, splice_feerate, opening_feerate and dual-fund RBF.

Note this leaves the paths where we cannot decline -- mutual close,
penalty and onchain resolution -- bounded only by the ceiling, which is
the right answer for a feerate that has a deadline attached.

channeld needs both, so channeld_init and channeld_feerates carry
our_feerate_max alongside feerate_max, and check_balances holds whichever
side is ours to the stricter one.  The multiplier is settable via
--dev-max-fee-multiplier, so widen to u64 before multiplying.

Finally, remove the UINT_MAX sentinels.  feerate_max returned UINT_MAX
when there were no estimates at all, and lightningd overwrote the bound
with 0xFFFFFFFF when fee limits were ignored.  Both are the bound a
peer's proposal is measured against, and we store what we accept -- a
u32 that large overflows the 25/24 RBF calculation downstream.  The
unknown path now falls back to FEERATE_CEILING, exactly as feerate_min
falls back to FEERATE_FLOOR, and ignore_fee_limits travels to channeld
and openingd as an explicit flag which relaxes the policy bounds but
never the ceiling.  That last part makes a documented promise false, so
the three places which say ignore-fee-limits means "any fee they want"
now say what it actually means.

Changelog-Fixed: channeld: a peer can no longer initiate a splice at an arbitrarily high feerate; it is now bounded like other remote-proposed feerates.
Changelog-Changed: lightningd: we no longer propose a feerate above 100000perkw (400 sat/vB) of our own accord, for channel opens, splices, commitment updates and dual-fund RBFs.
Changelog-Changed: lightningd: `--ignore-fee-limits` and `setchannel`'s `ignorefeelimits` still drop our policy bounds, but no longer accept a feerate above 1000000perkw (4000 sat/vB).
Changelog-Changed: JSON-RPC: `feerates` reports `max_acceptable` as the feerate ceiling rather than 4294967295 when no fee estimates are available.
(cherry picked from commit 4a10d71)
The previous commits bounded the feerates arriving on every path except
this one.  `accepter_start()` takes both `funding_feerate_perkw` and
`feerate_per_kw_commitment` straight off the wire from `open_channel2`
with no bound in either direction, and `dualopend_wire.csv` had no
min_feerate/max_feerate on `dualopend_init` at all -- so it is not that
the check was forgotten at the call site, it is that the bounds were
never plumbed to the daemon.

The `openchannel2` hook only *reports* `feerate_our_max`/`feerate_our_min`
to plugins.  With no plugin hooked nothing enforces them, so a peer could
name any feerate and we would sign for it and store it.  This is the
`open_channel2` plant named in the advisory, and the missing lower bound
is where the stored `feerate == 0` comes from.

Plumb the bounds into dualopend_init and dualopend_reinit -- reinit too,
since rbf_remote_start runs after a reconnect -- and apply them where the
values are parsed.  The two feerates get different floors:

 - The commitment feerate is held to feerate_min, as openingd holds
   open_channel's: too low and the commitment we are signing cannot be
   relayed at the point we need it.
 - The funding feerate is held only to FEERATE_FLOOR.  A slow funding tx
   is the opener's problem and RBF is the remedy, so our policy minimum
   would refuse perfectly good opens; but 0 is not a feerate, and it is
   exactly the value that leaves no valid next RBF step.

The RBF path needs the upper bound separately: check_funding_feerate()
only enforces the 25/24 step *upwards*, so without this a peer can walk
an accepted open's feerate up without limit, one RBF at a time.

rbf_local_start() is left alone deliberately: that feerate comes from our
own openchannel_bump, which lightningd already holds to our_feerate_max.

As with channeld and openingd, ignore_fee_limits relaxes the policy
bounds but never FEERATE_CEILING.

Changelog-Fixed: dualopend: a peer opening a channel with `--experimental-dual-fund` can no longer name an arbitrary funding or commitment feerate; both are now bounded as other remote-proposed feerates are.
(cherry picked from commit 4e653ad)
The bounds in the previous commits only apply to feerates arriving from
now on.  Nodes that already recorded an absurd `funding_feerate` for an
inflight splice or dual-fund RBF are stuck: the BOLT ElementsProject#2 rule that the
next RBF pays 25/24 times the last feerate is computed on a u32, so
anything above UINT_MAX/25 overflows and trips the
assert(next_feerate > last_feerate) in listpeerchannels.  Plugins call
listpeerchannels at startup, so such a node crash-loops with no way out
but to rewrite the stored value.

Clamp it on upgrade.  Note we write the feerate with db_bind_int(), so a
u32 above INT_MAX reads back negative; those are exactly the values that
overflow, hence the second clause.

Rewriting is safe: this feerate only sanity checks the fee the funding
transaction already pays and tells the user what the next RBF has to
beat.  It never feeds anything we have signed, and lowering it only
relaxes the check.

Changelog-Fixed: lightningd: nodes that stored an out-of-range feerate for an in-progress splice no longer crash-loop on `listpeerchannels`; the stored feerate is clamped on upgrade.
(cherry picked from commit 0393174)
The 25/24 RBF escalation from BOLT ElementsProject#2 is computed on a u32 in three
places, and two of them assert that the result came out larger than the
input.  It doesn't, for anything above UINT_MAX/25: the multiplication
wraps and the assert fires.  That value comes out of
channel_funding_inflights, which is to say out of whatever fee estimator
(ours or a peer's) produced it, so it is not an invariant we were ever in
a position to state.

The worse of the two is in json_add_channel.  listpeerchannels is
read-only introspection that plugins call at startup, so one bad row
turns into a crash-loop with no RPC left to repair it with.

check_funding_feerate() in dualopend already had the right shape for
this, so hoist it into bitcoin/feerate.c as next_funding_feerate() and
use it at all three sites rather than keeping three copies of the
calculation.  It also rejects a last feerate of 0, which is the other end
of the same problem: assert(last_feerate > 0) sits immediately above the
overflow one.

That makes next_feerate a field we can fail to produce, so the schema no
longer requires it: a last feerate of 0, or one below 24 where rounding
down lands back on itself, admits no valid next feerate, and there is no
honest value to print for one.  The bindings already modelled it that
way (Option<String> in cln-rpc, optional in the proto), so the required
list was the outlier.

The preceding commits bound every path a feerate arrives on, and the
migration repaired what was already stored, so the invariant does now
hold.  This is the belt to their braces: a future unbounded write path
should not be able to turn straight back into a crash-loop, and finding
out about it in listpeerchannels is finding out too late.  Since the
stored value is in range, the output is unchanged in every reachable
case.  openchannel_bump fails the command rather than the daemon.

Changelog-Fixed: lightningd: `listpeerchannels` no longer aborts when a channel has an out-of-range funding feerate stored for an in-progress open.
Changelog-Changed: JSON-RPC: `listpeerchannels`.`next_feerate` is now optional: it is omitted for an in-progress open whose stored feerate admits no valid next feerate.
(cherry picked from commit 84443b5)
Three tests, one per way an absurd feerate used to get in or blow up.

test_feerate_ceiling drives the fee source: bcli trims anything wider
than a u32 of perkb to exactly 0xFFFFFFFF, which is also the value whose
+3 wrapped in the perkb->perkw conversion, so a fee source producing an
absurdly *high* feerate used to arrive as 0perkw and get quietly raised
to the floor.  It fails without both the widened conversion and the
ceiling.

test_splice_stored_feerate_repaired_on_upgrade plants the poisoned value
on an inflight and then rewinds the db version so the clamping migrations
run over it, which is the upgrade an already-attacked node goes through.
Parametrized over the overflow value in both the form lightningd itself
would have stored it (db_bind_int makes it negative) and the form you get
writing it by hand, plus the zero case and a merely-large one.  Asserts
both that the row is repaired and that listpeerchannels -- which used to
abort right here -- agrees.

test_splice_feerate_too_high covers the accepter bound, which did not
previously exist: force_feerate gets us past our own check on what we
will pay, leaving the peer's bound as the thing under test.  The RPC
blocks because the splice_ack never comes, hence the thread.

Changelog-None

(cherry picked from commit a609189)
@nGoline
nGoline force-pushed the port-26.06.7-feerates branch from bc0d510 to 91b6031 Compare September 15, 2026 12:52
@nGoline
nGoline merged commit 65bdbc4 into ElementsProject:master Sep 15, 2026
45 checks passed
@nGoline
nGoline deleted the port-26.06.7-feerates branch September 15, 2026 20:22
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.

4 participants