Ethereum · mainnet · public data
What does block propagation
latency actually cost a validator?
Propagation-acceleration products share a common premise: blocks that arrive faster help validators earn more. Performance is usually reported in milliseconds, but a validator's outcomes are denominated in ETH, and the conversion between the two is rarely made explicit.
This study makes that conversion, on mainnet, through five tools, across four channels: three of revenue, one of cost.
Brian Woods · data: Xatu by ethPandaOps · 7,200 slots from 2025-06-11 · every number below is computed, not quoted, including the ones that came back null.
The identification
A deadline nobody chose#
The consensus spec tells an attester to vote 4,000 ms into the slot, for whatever head it can see
at that instant. A block landing at 3,950 ms is votable. At 4,050 ms it is not: the attester votes for the
previous block and forfeits TIMELY_HEAD. Nobody chose that cutoff, and no proposer can steer gossip to
land on one side of it with millisecond precision. That is a textbook
sharp regression discontinuity. RANDAO
assigns attesters to slots, so exposure to a late block is as-good-as-random.
I am not comparing fast operators to slow ones; that comparison is hopelessly confounded. I compare the same validators across slots that, by lottery, happened to have an early or a late block.
The three channels
Latency doesn’t cost you once. It costs you three ways.#
The three channels have completely different shapes. Attestation losses are tiny but constant: a slice of one validator's pay, shaved off every time a block runs late. Proposal losses are the mirror image: rare, but when a slow block gets reorged out the proposer loses everything at once. And MEV isn't a loss at all: it is extra income a proposer could capture by waiting longer to publish, if faster propagation bought back the safety margin that waiting spends.
Rolling all three into one "cost of latency" number would hide the thing a buyer actually needs to know: faster propagation barely moves the first two, and almost all of its dollar value sits in the third, which only pays if the operator changes how it behaves. That is why every step below prices the channels separately.
(There is also a fourth channel, D: bandwidth, priced in section 07. It is kept apart because it cuts server costs rather than earning validator revenue.)
DuckDB · Xatu · pandas
Panel construction#
Xatu is ethPandaOps’ public beacon-chain dataset: plain parquet over HTTPS, no auth. DuckDB reads it in place and prunes columns, so a query touching 3 of 40 columns downloads 3 column chunks. No warehouse, no ETL. The unit is the slot.
from src.xatu import connect, XatuPaths, ATTESTATION_DEADLINE_MS # 4000 ms
from src.panel import build_slot_panel
build_slot_panel(con, XatuPaths(cfg.xatu_base_url), dt.date(2025, 6, 11),
min_sentries=cfg.min_sentries, local_dir=cfg.raw_dir)
df = con.execute("SELECT * FROM slot_panel ORDER BY slot").df()
# Treatment: MEDIAN arrival across >=5 sentries, joined on the CANONICAL block root,
# so a reorged competitor block cannot pollute the timing.
df["late"] = (df.arrival_ms > ATTESTATION_DEADLINE_MS).astype(float)
Four things in that SQL are load-bearing: the treatment is the median arrival across ≥5
sentries joined on the canonical block root (a reorged competitor must not pollute the timing); attestations are
read from day D and D+1 (an attestation may land 32 slots later; truncation would masquerade
as a missed attestation); attesters are de-duplicated at the (slot, committee, voted-root) grain with list ops, never
UNNEST (which explodes one day into ~200M rows); and the panel left-joins from proposer_duty,
so a slot with no block survives as a missed proposal instead of vanishing.
len(validators) double-counts attesters, badly enough to
produce a negative missed-attestation rate.And a second one, found while building this notebook
A missed proposal has no block, so it has no arrival_ms, and no blob_count,
tx_count or payload_bytes either. The covariates vanish exactly when the outcome fires. Any
model of the Proposals channel built on own-block features is silently fitted to a sample from which every event
has been deleted. Measured on this panel:
| frame | rows | proposal failures |
|---|---|---|
| all proposer duties | 7,200 | 49 |
dropna(blob, tx, payload) | 7,151 | 0 |
Ninety-nine percent of the rows survive, and 100% of the events die. A model fitted there would report a beautiful R² on a question it can no longer see. The fix is lagged network conditions, which come from a slot’s neighbours and therefore exist for every duty, block or no block:
# A missed proposal has NO block -- so no arrival_ms, and no blob/tx/payload either.
# The covariates vanish exactly when the outcome fires. So the Proposals channel must be
# modelled on LAGGED conditions, which come from a slot's NEIGHBOURS and exist for every duty.
g = df.arrival_ms
df["prev_arrival"] = g.shift(1).ffill()
df["roll_arr_mean"] = g.shift(1).rolling(32, min_periods=4).mean().ffill()
df["roll_arr_p90"] = g.shift(1).rolling(64, min_periods=8).quantile(.90).ffill()
df["roll_late"] = df.late.shift(1).rolling(64, min_periods=8).mean().ffill()
statsmodels · linearmodels
Difference-in-differences, clustered#
This repo was originally scoped as a staggered DiD on mump2p adoption by the seven publicly-named Optimum partners. That study cannot be run, and the reasoning is worth more than the study would have been.
1 · There is no staggering. All seven were named in a single press release on 2025-06-24. Callaway–Sant’Anna
exists to exploit adoption-timing variation; with one cohort it degenerates to a 2×2 DiD.
2 · The treatment never happened in the data. mump2p has never run on Ethereum mainnet: it is a
Hoodi/private-testnet product, and Xatu is mainnet. The treated operators’ mainnet validators were never treated:
the treatment indicator is identically zero across the panel.
You cannot estimate the effect of a treatment that did not occur. So the honest thing to put in a notebook is not a
fabricated effect. It is a null calibration: run the full machinery against a placebo where the truth is
τ = 0 and confirm it finds nothing. An estimator that finds effects is worthless unless it also fails to
find them when they are absent.
import statsmodels.formula.api as smf
from linearmodels.panel import PanelOLS
# mump2p never shipped to MAINNET, so on Xatu the treatment indicator is IDENTICALLY ZERO.
# You cannot estimate the effect of a treatment that did not occur. What you CAN do is prove
# the estimator does not hallucinate one. Placebo: the truth is tau = 0.
df["treated"] = (df.proposer_index % 14 < 7).astype(int) # 7 pseudo-"partners"
df["post"] = (df.slot > df.slot.median()).astype(int)
df["did"] = df.treated * df.post
m = smf.ols("correct_head_rate ~ treated + post + did", data=df).fit(
cov_type="cluster", cov_kwds={"groups": df.epoch})
fe = PanelOLS.from_formula(
"correct_head_rate ~ did + EntityEffects + TimeEffects",
data=df.set_index(["operator", "epoch"])
).fit(cov_type="clustered", cluster_entity=True)
| channel | τ (placebo) | clustered SE | p | 95% CI | verdict |
|---|---|---|---|---|---|
| Attestation | -0.00237 | 0.00222 | 0.28 | [-0.0067, +0.0020] | null, as it must be |
| Proposals | -0.00527 | 0.00404 | 0.19 | [-0.0132, +0.0027] | null, as it must be |
0.00233 (iid) to
0.00222, a factor of 0.95×. Essentially nothing. That is the
correct behaviour and worth saying out loud: clustering is not a knob that always widens intervals: it corrects
for within-cluster correlation, and a randomly-assigned placebo has none to correct. The real study clusters by
day, where shared network conditions and blob demand genuinely do correlate the errors.Hoodi · the one network where mump2p runs
Why the DiD didn’t just move to the testnet#
The obvious rejoinder to step 02: mump2p is deployed somewhere: Ethereum’s Hoodi testnet, since 2025-06-24. So I went there. Two things break the textbook design.
First, timing. Xatu’s attestation coverage for Hoodi only begins in late October 2025, after the deployment window this study needs: the partners were announced 2025-06-24 and the vendor’s results were captured in September–October 2025. Attestation outcomes (head votes, inclusion distance) are available from November 2025 onward and can be studied today, but a difference-in-differences needs outcomes on both sides of the adoption date, and the pre-adoption side was never recorded. For the window that matters, identification has to run on propagation physics instead.
Second, and more fundamental: there is still no staggering. The operator clusters all move on the same dates, so “adoption timing” never becomes a usable second difference, with or without attestation data.
Why mainnet Xatu carries the study anyway. The money is on mainnet: real MEV, real rewards, the real validator set, none of which exist on a testnet, and Channel C is ~90% of the dollars. Hoodi answers a different and narrower question (“did the overlay visibly carry blocks?” Answer: no, as far as public data can see), and it answers it with only 4 sentries feeding the libp2p layer. So: mainnet for the physics and the pricing, Hoodi for the adoption forensics, and the adoption-detection machinery built for that search is reusable on mainnet the day a real rollout happens. That is the staggered DiD, pre-built and waiting for its treatment.
LightGBM · scikit-learn · CUPAC
Buying precision instead of data#
The channels that matter are rare-event channels: 0.68% of duties miss a proposal;
0.83% of blocks cross the deadline. Rare events mean wide intervals, and wide intervals mean you
cannot price the product. CUPAC buys precision without buying more data: predict the outcome from pre-treatment
covariates, then use the out-of-fold prediction as a control variate. Variance falls by roughly
corr(Y, Ŷ)².
import lightgbm as lgb
from sklearn.model_selection import TimeSeriesSplit
# CUPAC: predict Y from pre-treatment covariates; use the OUT-OF-FOLD prediction as a control
# variate. Var falls ~ corr(Y, Y_hat)^2. Out-of-fold is not optional -- predictions fitted on
# the rows they adjust would absorb the treatment effect itself. Folds must be TIME-ORDERED.
oof = np.full(len(f), np.nan)
for tr, te in TimeSeriesSplit(n_splits=5).split(X):
g = lgb.LGBMRegressor(n_estimators=400, learning_rate=.05, num_leaves=31,
min_child_samples=40, random_state=SEED)
g.fit(X[tr], y[tr]); oof[te] = g.predict(X[te])
base = smf.ols("_y ~ _d", data=f).fit(cov_type="cluster", cov_kwds={"groups": f.epoch})
cupac = smf.ols("_y ~ _d + cupac", data=f).fit(cov_type="cluster", cov_kwds={"groups": f.epoch})
First: prove the implementation works
Before trusting CUPAC on real data, I check that the implementation can shrink an interval when there is
genuinely signal to exploit. The test is a synthetic dataset where the answer is known: the outcome is built
partly from the covariates (so a model really can predict some of it), a randomised treatment with a true effect
of τ = -0.25 is added, and the rest is noise. Theory predicts the standard error
should fall by 1 − √(1 − R²).
| run | τ | out-of-fold R² | corr(Y, Ŷ) | CI before | CI after | shrinkage |
|---|---|---|---|---|---|---|
| Validation (planted -0.25) | -0.2457 | +0.250 | 0.516 | 0.1279 | 0.1098 | +14.2% |
| Attestation (real) | -0.4030 | -0.137 | +0.035 | 0.1847 | 0.1844 | +0.1% |
| Proposals (real) | -0.0007 | -0.290 | -0.003 | 0.0081 | 0.0081 | -0.6% |
The validation lands where theory says it should: at R² = 0.250, 1 − √(1 − R²) predicts a
13.4% SE reduction; I measured 14.2%. The estimator is
sound. It simply has nothing to work with on the real panel.
corr(Y, Ŷ) = 0.035 and out-of-fold R² is negative: LightGBM predicts
worse than the mean. At slot grain, one day of Xatu contains no covariate that forecasts a slot’s fate: block arrival is
close to i.i.d. across slots, because idiosyncratic proposer timing games dominate, not persistent network state. CUPAC’s
native habitat is an experiment where each unit has a pre-period, and a slot has no pre-period. The
right unit here is operator × day, which needs the multi-day panel, not one day of slots. Hunting for a specification that
flattered the method would have been the easy path; this is what the data actually returned.late) and CUPAC still shrinks the interval by
+13.2% while the point estimate drifts to
-0.1136 against a truth of -0.25. CUPAC’s variance guarantee
assumes D ⟂ X. Given a treatment correlated with the covariates, it will hand you a tighter interval around a
more wrong number. Precision is not accuracy, and a variance-reduction method will never tell you which one it
just gave you.PyMC · ArviZ
The tail is the product#
Mean latency is the wrong KPI. Mean arrival is 2.35 s, comfortably safe. Yet 0.83% of blocks still cross 4,000 ms, and only those cost anything. A product that shaves 200 ms off the mean and nothing off the p99 is worth approximately zero. So model the arrival distribution.
import pymc as pm, arviz as az
with pm.Model():
mu = pm.Normal("mu", 2.3, 1.0) # grand mean arrival (seconds)
tau = pm.HalfNormal("tau", 0.5) # between-hour sd
z = pm.Normal("z", 0, 1, shape=n_hours) # NON-CENTRED: the centred form funnels
theta = pm.Deterministic("theta", mu + tau * z)
sigma = pm.HalfNormal("sigma", 1.0)
nu = pm.Gamma("nu", 2.0, 0.1) # HEAVY TAIL: gossip arrival is not Gaussian
pm.StudentT("obs", nu=nu, mu=theta[hour_idx], sigma=sigma, observed=y)
idata = pm.sample(1000, tune=1000, chains=2, target_accept=.9)
ppc = pm.sample_posterior_predictive(idata)
p99 = np.percentile(ppc.posterior_predictive["obs"], 99, axis=-1) * 1000 # decision variable
Non-centred because the centred parameterisation funnels and the sampler jams against small τ.
Student-t because gossip arrival has fat tails, and a Gaussian would be dragged by them and under-predict
the p99, understating exactly the risk being priced. Sampling was clean: max R̂ = 1.000,
0 divergences.
ν ≈ 2.1 the Student-t tail is heavier than the data’s. The model
overstates the tail. That makes it conservative for risk-pricing, but it is a misfit, not a triumph, and the fix is a
censored or mixture likelihood, not a heavier ν. Publishing the failed check is the entire point of running
one.scikit-learn
TimeSeriesSplit, and why the alternative lies#
Slots are a time series. A shuffled KFold trains on slot 9,000 to predict slot 100, leaking the
future into the past. And because the CUPAC covariate in Step 03 is built from those out-of-fold predictions, the
leak would not merely inflate an R²; it would silently bias the very treatment effect it exists to de-noise.
from sklearn.model_selection import TimeSeriesSplit, KFold
# Slots are a TIME SERIES. A shuffled KFold trains on slot 9,000 to predict slot 100 -- leaking
# the future into the past. And because the CUPAC covariate is BUILT from these out-of-fold
# predictions, the leak would not merely inflate an R2; it would silently bias the very
# treatment effect it exists to de-noise.
oof_r2(TimeSeriesSplit(n_splits=5)) # honest
oof_r2(KFold(n_splits=5, shuffle=True, random_state=0)) # leaks -- and "looks" better
| splitter | out-of-fold R² | honest? |
|---|---|---|
TimeSeriesSplit(5) | -0.1369 | yes: only ever trains on the past |
KFold(5, shuffle=True) | -0.1049 | no: trains on the future |
The leaky splitter reports the better number. Both are negative here, which is its own answer, but note the direction: shuffling buys 0.032 of free R² out of nothing but leakage. That is the danger in miniature: the metric improves, the science degrades, and nothing in the traceback tells you.
MEV channel
Speed doesn’t earn MEV. It buys delay budget.#
Measured from 212,162 slots of relay bid traces: the best bid available to a proposer roughly doubles across the slot, then plateaus right after ~3.5 s: builders stop bidding on a block that cannot beat the deadline, because such a block is worthless.
So the commercial argument inverts. A faster transport does not earn MEV by being fast. It earns MEV by buying delay budget: if transit is Δ ms quicker, the proposer can publish Δ ms later and still land at the same arrival time: identical reorg risk, strictly better bid. That conclusion is visible only because the three channels were kept apart.
How that becomes the dollar figure, step by step
- The slope. dV/dt = 9.12×10⁻⁶ ETH/ms (≈0.0091 ETH per second of delay): the slope of the curve charted above, fit over its 1,000–4,000 ms window (where timing-game proposers actually decide), from the full 30-day June 2025 panel of relay bid traces (212,162 slots). Every dollar figure that follows traces back to this one measurement.
- The compressible milliseconds. Only transit compresses: the spread between the first and the 90th-percentile sentry sighting of the same block, mean 1,002 ms on the panel. The ~1.4 s proposers deliberately wait to accrue bids is untouchable by any transport. At the vendor's 6×: 1,002 × (1 − 1/6) ≈ 835 ms saved, capped per slot at the headroom left before the 4,000 ms deadline (delay you don't have can't be spent).
- The behavioural step. The proposer publishes those 835 ms later, lands at the same arrival time, and harvests 835 × 9.12×10⁻⁶ ≈ 0.0076 ETH ≈ $13.75 of extra bid per block. This step is a config change, not an installation; skip it and Channel C pays ~nothing.
- The frequency. Proposer selection is stake-proportional: 2,628,000 slots/yr ÷ ~881k active validators ≈ 3.0 blocks per validator-year → ≈ $41.0/validator/yr uncapped; applying the per-slot headroom cap on the observed slot mix trims it to $38.13.
- The fleet. × 147,737 validators across the seven partners = ≈ $5.63M/yr: the Channel C column in the table below, and ~92% of the headline number at the top of this page.
By operator · 6× · ETH @ $1,805.50
All four channels, priced per operator#
The same model, run for each of the seven named partners at the vendor's headline 6× speedup, plus a fourth channel, D: bandwidth, which is different in kind: it is an opex saving, not revenue. RLNC-coded pub/sub replaces gossipsub's redundant flooding (~4× duplication measured in the literature, ~1.2× residual for the coded scheme), and it scales per node, not per validator. Its dollar value depends almost entirely on where you host: at cloud egress prices ($0.09/GB) it is real money; on a bare-metal box with bundled transit ($0.001/GB) it rounds to nothing. I price both and refuse to pick one.
| operator | vals | nodes* | A: attester | B: reorgs | C: MEV | D: bandwidth (cloud) | D (metal) | TOTAL | $/day |
|---|---|---|---|---|---|---|---|---|---|
| Kiln | 47,500 | 95 | 117,923 | 8,569 | 1,811,208 | 32,478 | 361 | $1,961,609 | 5,374 |
| P2P.org | 31,862 | 64 | 79,100 | 5,748 | 1,214,920 | 21,880 | 243 | $1,315,900 | 3,605 |
| Everstake | 29,300 | 59 | 72,740 | 5,286 | 1,117,229 | 20,171 | 224 | $1,210,140 | 3,315 |
| Ebunker | 11,537 | 23 | 28,642 | 2,081 | 439,914 | 7,863 | 87 | $476,419 | 1,305 |
| InfStones | 11,500 | 23 | 28,550 | 2,075 | 438,503 | 7,863 | 87 | $474,916 | 1,301 |
| Blockdaemon | 10,788 | 22 | 26,782 | 1,946 | 411,354 | 7,521 | 84 | $445,657 | 1,221 |
| Luganodes | 5,250 | 10 | 13,034 | 947 | 200,186 | 3,419 | 38 | $216,638 | 594 |
| ALL SEVEN | 147,737 | 295 | 366,770 | 26,653 | 5,633,313 | 100,854 | 1,121 | $6,100,937 | 16,715 |
* nodes ≈ validators / 500 keys per beacon node. Channel D assumes gossip duplication 4.0× → 1.2× under RLNC on ~542 KB/slot of measured wire payload (block + 3.81 blobs), and should be re-measured after PeerDAS ships. TOTAL = A + max(B, C) + Dcloud; B and C are mutually exclusive uses of the same saved milliseconds, D is additive because it spends none of them.
Adoption sweep · 6× · $/validator/yr
The edge is zero-sum: adoption has a sweet spot#
Channel C is not new money; it is repriced auction share. An adopter wins timing-game value partly from proposers who didn't adopt. Sweep the adoption share and the structure falls out: per-adopter value peaks at 40% adoption ($26.04/validator/yr), total captured value peaks at 55% (~$9.8M/yr), and at 100% adoption the MEV edge is exactly zero: everyone waits longer, nobody gains relative position, and all that survives is the non-rivalrous A + B + D (~$3.34/validator/yr).
| adoption | validators | C (MEV) | TOTAL/adopter | non-adopter P&L | adopt-vs-not spread | all adopters $M/yr |
|---|---|---|---|---|---|---|
| 5% | 44,027 | 4.53 | $7.69 | -0.24 | 7.25 | 0.34 |
| 10% | 88,055 | 8.58 | $11.74 | -0.95 | 12.01 | 1.03 |
| 16.8% · the 7 partners | 147,737 | 13.31 | $16.47 | -2.68 | 18.47 | 2.43 |
| 30% | 264,165 | 20.02 | $23.18 | -8.58 | 31.08 | 6.12 |
| 40% · per-adopter peak | 352,220 | 22.88 | $26.04 | -15.25 | 40.61 | 9.17 |
| 50% | 440,275 | 19.07 | $22.23 | -19.07 | 40.61 | 9.79 |
| 55% · total-value peak | 484,302 | 17.16 | $20.32 | -20.97 | 40.61 | 9.84 |
| 75% | 660,412 | 9.53 | $12.70 | -28.60 | 40.61 | 8.38 |
| 100% | 880,550 | 0.00 | $3.34 | -38.13 | 40.79 | 2.94 |
Two commercial readings, both honest: the seller's: early adopters capture the most per seat, and the spread between adopting and not ($40.61/validator/yr at the plateau) is the real product, since past ~40% you buy it to avoid losing rather than to win; and the buyer's: the pitch deck number assumes your competitors haven't bought it yet.
The JD, answered
Every “experience with X?” → “here’s where I used it on your problem”#
| tool | step | what it actually did here |
|---|---|---|
DuckDB + Xatu | 01 | Slot panel straight off public beacon parquet: no warehouse, no ETL. |
statsmodels | 02 | OLS DiD, cluster-robust covariance, placebo/null calibration. |
linearmodels | 02 | PanelOLS two-way fixed effects, entity-clustered. |
LightGBM | 03 | CUPAC control variate: out-of-fold outcome prediction. |
scikit-learn | 03 · 05 | TimeSeriesSplit CV; leakage demo vs shuffled KFold. |
PyMC + ArviZ | 04 | Hierarchical non-centred Student-t latency model; posterior-predictive p99. |
pandas / NumPy | all | Panel shaping; lagged network-condition features. |