← Tech docs

How outbound fees get set.

June 2026

Every channel's outbound fee is computed from scratch on each 2-hour pipeline. This note answers the common questions about how the stack works and why it is shaped the way it is. The companion notes go deeper on the two trickiest pieces: how an idle channel finds a sellable price and how the refill budget is set.

What actually sets a channel's fee?

A fixed pipeline, evaluated in order. A manual overwrite (aka pin) short-circuits everything; otherwise the fee is a clamp of three computed inputs:

1. pin set?  → use the pinned ppm (warns if it sits below the floor)
2. base   = sigmoid(local_ratio)                     # liquidity state on the channel
3. mult   = market_multiplier (+ fast-drain bump)    # demand/market signal
4. floor  = soft ratchet of last_refill_ppm × 1.1    # accounting for refill cost, 0 if never refilled
5. target = clamp( max(base × (1 + mult), floor), 0, 5000 )
6. broadcast only if hysteresis permits (i.e. avoid spamming the network)

Because the inputs are combined with max and min rather than blended into a weighted average, exactly one of them "wins" at any moment, and the engine records which — sigmoid, sigmoid+mult, or floor — in the reason it logs for every change. A surprising fee is then easy to explain after the fact. Base fee is always 0; all of the price lives in the ppm rate.

How does the base curve work?

The base fee is a logistic (sigmoid) function of the channel's local balance ratio, with a low asymptote when the channel is full and a high one when it is depleted. The closed form, exactly as implemented:

f(ratio) = MIN + (MAX − MIN) / (1 + exp( K · (ratio − MIDPOINT) ))

SIGMOID_MIN_PPM  = 25     # full channel → drain it cheaply
SIGMOID_MAX_PPM  = 750    # depleted channel → defend with price
SIGMOID_K        = 8.0    # steepness
SIGMOID_MIDPOINT = 0.5    # inflection at balanced
# exp() is guarded at ±700 to avoid overflow at extreme ratios;
# the result is rounded and clamped back into [MIN, MAX].

Note the sign: at low ratio the exponent is negative and the fee approaches MAX (defend a depleted channel); at high ratio the exponent is large positive, and the fee approaches MIN (incentivise transit). With defaults the curve looks like this:

local   fee
 5%     731 ppm
20%     690 ppm
50%     388 ppm
80%      85 ppm
95%      44 ppm

What exactly is local_ratio?

Deliberately naive: local_balance ÷ capacity, straight from LND's ListChannels. It is not adjusted for the channel reserve, the commitment fee, or in-flight (unsettled) HTLCs.

Base fee is fixed at FEE_BASE_MSAT = 0 — zero base is current routing best practice.

Why a sigmoid instead of a straight line?

The steepness (K = 8) keeps both tails flat and concentrates the movement around the 0.5 midpoint. A channel at 80% vs 90% full is priced almost the same, however a small drift inside the healthy middle has larger impact. A linear map would over-react at the edges and barely move through the middle.

What is the market multiplier?

It is the lever that lets a channel's price drift toward what the market is actually paying, on top of what its balance alone implies. A slow, per-channel demand scalar in [-0.5, +1.0] that modulates the base curve: adjusted = base × (1 + mult). At −0.5 it halves the base; at +1.0 it doubles it. With the 750 ceiling that puts the demand-amplified outbound max at 1500 ppm — still well under the hard ceiling (i.e. 5000 ppm).

How does the multiplier move?

Two ways, on two clocks:

  • Nightly drift (slow baseline). The recompute_signals job nudges each channel by ±MARKET_MULT_STEP (0.15): up if it forwarded anything in the last 24h, down if it has been silent ≥3 days, no change in between. Clamped to the [−0.5, +1.0] band. Roughly 7 busy nights to ramp from neutral to the cap.
  • Fast-drain bump (intra-day, up-only). The nightly step is too slow for a channel that is actively bleeding, so the 2h loop adds an immediate +MARKET_MULT_FASTDRAIN_STEP (0.40) when a channel is both in the sub-20% defense zone and dropping forwards for lack of liquidity (an INSUFFICIENT_BALANCE event in forward_fail_log). It applies the same cycle, capped at +1.0, and never moves down — downward movement is exclusively the nightly step.

Why is the signal "volume-blind"?

Deliberately. A single 1-sat forward bumps the multiplier exactly as much as a 1M-sat forward — both just flip the channel to "busy" for the night. The multiplier answers a binary question ("is anyone forwarding through this at all?"). Volume does matter elsewhere — it is what gives the channel enough evidence to be calibrated by the profitability gate.

What is the floor, and why is it "soft"?

The floor recoups what refilling the channel cost: last_refill_ppm × REBALANCE_FEE_MARGIN (set at 1.1), where last_refill_ppm is the price of the most recent successful rebalance into that specific channel. Selling its outbound below that is a guaranteed loss. But the floor is a ratchet, not a hard minimum: it holds at full level while the channel forwards, and decays toward the market-clearing fee once the channel sits idle — otherwise a channel refilled at an expensive price could be stranded above what anyone will pay, earning nothing. A forward does not snap it back up; only a fresh refill re-arms it. That whole mechanism has its own note: how an idle channel finds a sellable price.

What sets the ceiling?

A hard FEE_HARD_CEILING_PPM of 5000 — the last line of defense against bad data. It is set equal to REBALANCE_MAX_BUDGET_PPM, so a channel could always charge enough outbound to recoup the most we would ever pay to refill it. Even the floor cannot exceed it.

When does a new fee actually go out?

Broadcasting an update send gossips network-wide and resets peers' view of you. So a change becomes a real channel_update if it clears the hysteresis gate (_should_broadcast), evaluated top to bottom:

1. tolerance   — skip if the move is BOTH < 10 ppm AND < 10% relative
2. snap        — a change ≥ 30 ppm always broadcasts, even in cooldown
3. edge cross  — crossing the 20% / 80% balance boundary escapes cooldown
4. cooldown    — otherwise skip if the last broadcast was < 6h ago
5. normal      — cleared tolerance, ≥ 6h elapsed → send it

The sigmoid shape decides what the fee is; hysteresis decides whether to tell the network. The two snap/edge escapes exist because a big jump or a balance crossing the defense boundary are exactly the moments you do not want to sit on a stale fee.

What about a depleted channel when the market says "go lower"?

Blocked. Inside the sub-20% defense zone the multiplier can only raise the fee, never lower it — we never cheapen a channel we are actively trying to refill. Above 80% local the opposite holds: the market term is free to raise the fee to earn more on the outflow.

Can I just override the whole thing?

Yes. ln-operator overwrite_fee <alias> <ppm> pins a channel to a fixed rate (stored in the fee_overrides table); it holds across every pipeline run until you clear_fee. Pinned channels skip the whole calculation, so a pin can legitimately sit below break-even — but adjust_fees warns at the moment you set one under the channel's refill floor, and the dashboard tags every fee row as auto or 📌 pin so you can always tell which is which.

What does this deliberately not do?

It does not anchor to neighbours' fees from the local graph — that data is sparse and lagging enough that "the market rate" is often fiction, and importing it just imports someone else's mistake. The only external anchor is a cost we measured ourselves: what the last refill actually paid. Everything else is derived from this node's own liquidity state and forwarding history.

How do I see which layer set a fee?

Every change writes a reason string into fee_updates that exposes all four inputs and the winner in one line:

sigmoid=388 mult=-0.50 floor=2669(↓from 2861) local=0.18 → 2669 [floor-decaying]

The trailing tag is the source: sigmoid, sigmoid+market, floor, floor-decaying, or pin. The slow inputs live in channel_signals (market_multiplier, last_fee_update_ts, last_local_ratio, and the floor-ratchet columns).

Like the thinking? Run it on your own node.