The idea
The estimator lives inFrugalMedianLibrary and follows the Frugal-1U / Frugal-2U algorithm (Ma, Muthukrishnan, Sandler, 2013), adapted to Solidity integer math. It tracks three state variables: approxMedian, step, and positive.
Every time a new priority fee comes in:
- If the new value is bigger than the current estimate, the estimate moves up by
step. - If it is smaller, the estimate moves down by
step. stepis not fixed. It grows while the moves keep going the same direction (roughly 1% of the value per iteration, viastepIncrement), so the estimate speeds up when it needs to catch up.- As soon as the direction flips,
stepis decremented bystepIncrementand, if it would still be above 1 after that, it’s force-clamped down to exactly 1. In practice this meansstepends up small right after a flip — usually 1, but it can also land on a small negative value (see transaction #16 in the worked example below, wherestepcomes out as-1). Either way, it acts as a brake against overshooting and bouncing back and forth. - If a move would jump past
newNumber, the estimate is clamped tonewNumberand the leftoverstepis corrected — this happens in the overshoot-correction block ofupdateApproxMedian, one mirrored version for each direction (upward and downward).
Why convergence is quadratic
Becausestep accumulates instead of resetting each time, a sustained run in one direction makes the per-step move grow like 1%, 2%, 3%, 4%… of the value. Total distance covered therefore grows like n². Catching up to a big level jump takes about sqrt(2 × 100) ≈ 14 consecutive same-direction steps (with STEP_DIVISOR = 100), not ~100 as a flat 1% step would need.
Why it resists outliers
Because each step is small (~1% of the value) and a direction flip immediately shrinksstep back down, one huge spike barely moves the estimate. It creeps toward the outlier instead of jumping to it. This is what makes approxMedian resistant to attacks like “spam a batch of high-tip swaps to yank the median.” PulseHook adds two more layers on top of this — see Snapshots and update gating below.
Example with 20 transactions
Below is a sequence of priority fees (in gwei). Most values sit around 10-13, but transactions #8 and #16 are clear outliers (300 and 250), simulating a manipulation attempt:
(direction: “up” means
positive = true after this step, “down” means positive = false)
What this table shows
- Fast start (tx 1-7).
approxMedianstarts at 0 and catches up to the real level (~10-11) in about 5-6 same-direction steps. That’s the quadratic convergence from the growingstep. - Outlier at tx 8 (300 gwei). The real median barely moves (11 → 11.5) and
approxMedianonly moves by its currentstepof 5, from 12 to 17. It does not jump to 300. - Quick correction (tx 9-11). As soon as the next value (11) is below the estimate again,
stepshrinks and the direction flips, and the estimate settles back into the 9-13 range. - Steady state (tx 12-20). After the transient,
approxMedianstays in a tight 10-12 band, matching the real rolling median (~11), even through a second outlier (250 at tx 16) — note howstepat tx 16 lands on-1rather than a flat1, exactly the “small, not always exactly 1” behavior described above.
Snapshots and update gating
PulseHook doesn’t feed the fee formula from the live approxMedian directly. Two extra layers sit on top, both meant to stop an attacker from moving the reference value within a single block.
One snapshot per block
- On the first swap that touches a registered pool in a new block, the current
approxMedianis copied intoblockMedianSnapshots, a circular buffer withSNAPSHOT_WINDOW = 15slots (about 15 blocks, similar to Uniswap’s Truncated Oracle hook). - Every other swap in that same block is a no-op here. No matter how many swaps happen in one block, at most one snapshot gets written.
- The value the fee formula actually uses (
referenceMedian, i.e.M) is the average of the last 15 snapshots, not the liveapproxMedian. Shifting it takes several blocks of sustained effort, not a single burst of swaps.
Median updates require real price movement
- The live
approxMedianis only updated when a swap moves the pool’s price (its tick) far enough from the tick recorded at the last accepted update. - How far is “far enough” scales with the pool’s liquidity, roughly
~sqrt(REFERENCE_LIQUIDITY / liquidity): a shallow pool needs a bigger tick move (a small trade can swing it cheaply), a deep pool needs a smaller one (moving its price is already expensive). - This keeps a burst of same-price or dust swaps from nudging the median with no real price movement behind them.
The exact snapshot-window size and the liquidity-scaling formula for “far enough” live in
SnapshotWindowLibrary and TickCheckerLibrary, which aren’t covered by this page’s source review — treat the 15 and the sqrt(REFERENCE_LIQUIDITY / liquidity) figures above as documentation, not independently verified against those libraries.Batch helper
FrugalMedianLibrary also exposes frugalMedian(int256[] memory sequence), which folds updateApproxMedian over a whole sequence at once starting from a fresh estimator. PulseHook itself never calls this in its per-swap flow — it always feeds one observation at a time — this helper exists for tests and off-chain backfills.
Key takeaway
approxMedian is not a live snapshot of the true median. It’s a smoothed, noise-resistant approximation that:
- converges to the real value in about 10-15 same-direction updates, and
- mostly ignores single outliers thanks to the small step size and the direction-flip reset.
referenceMedian in PulseHook, especially with the snapshotting and update-gating layered on top (see above).