The largest DeFi protocol by TVL today is Lido. It runs Ethereum's biggest liquid-staking system. You deposit ETH, Lido stakes it across roughly half a million validators, and in return you hold stETH, a token whose balance grows as those validators earn staking rewards.
For that balance to grow, someone has to report how much ETH Lido's validators actually hold, around 9.3 million ETH today. That figure comes from a committee of nine oracle operators voting on what an off-chain indexer told them. If five of nine agree, the number lands, and stETH rebases for everyone.
This week an Ethereum mainnet contract verified a proof of the same figure without a vote, covering Lido's exact registered validator set and its consensus-layer balance at a single finalized beacon slot, in one transaction, trustlessly.
At beacon slot 14,952,064 the proof accounts for all 488,374 registered keys. 467,856 matched to live validators holding 8,793,794 ETH, 20,518 still sitting in the deposit queue. The two numbers add to the first. Nothing stranded, nothing missing.

The transaction is public, and so is the contract that verified it.
The expensive half
Proving a staking protocol's balance splits into two jobs, and they differ drastically in difficulty.
The first is summing balances. Ethereum's beacon state stores validator balances in a packed list, four to a 32-byte chunk, so summing all of them is around 443,000 SHA-256 compressions. That's cheap, and several teams have already done it.
The second is deciding whose balances to sum. That one's expensive. Four separate teams have been funded to build a Lido ZK oracle since 2023, and none is in production today.
Why it's expensive comes down to a fact Lido acknowledged in LIP-23:
...the inclusion criterion for a validator to be considered belonging to Lido differs from Lido Oracles and may result in an error, given that anyone can spawn a validator with Lido's withdrawal credentials. Hence, ZK-proved values can indicate only the upper limit at this stage.
Nobody else's money is at stake, but the validator looks like Lido's if all you check is the credential. So a proof that scans the beacon state for Lido-credentialed validators gives you an upper bound, not the actual set. Lido budgets for the gap on-chain. Its sanity checker permits 0.5% of disagreement between a ZK second opinion and the committee, which is about 48,700 ETH.
The real set doesn't live on the consensus layer at all. It lives on the execution layer, in the staking modules' own storage, where operators register keys and a counter increments each time one gets deposited. That's the set to prove. Proving a validator belongs to it is easy. Proving you found all of them is not.
Closing the set with Lido's own counters
Here's how the execution-layer half works, with real numbers from the block we proved.
Every registered key sits in a staking module's storage at a position derived from its operator's index. We enumerate all of them, then close the enumeration against counters Lido already maintains on chain.
The closure is three levels. Each operator has a counter of how many of its keys have been deposited. Each module has a total. And the Lido contract has a global depositedValidators. Here's what those look like at block 25,714,954:
Curated 448,177 across 39 operators
SimpleDVT 12,079 82
Community Staking 26,108 612
Curated v2 2,010 45
───────
488,374 = depositedValidators
The proof reads every one of those counters through a Merkle-Patricia storage proof against the block's state root. It walks each module's operators from index 0 to the on-chain operator count with no gaps. Then it requires that the per-operator counters sum to the module total, that the module totals sum to the global counter, and that the enumerated key list has exactly that many keys in it.

That's what makes omission impossible rather than merely detectable. There's no valid proof that drops a key. Take one out and the operator's counter no longer matches, so the module sum no longer matches, so the global no longer matches. The prover can't fabricate the counters either, because they're read from Lido's own storage under a state root the proof is anchored to.
We're not asking Lido to trust our list. We're deriving the list's length from a number Lido already publishes on chain and proving our list has exactly that length.
Where the last failure actually happened
On July 25th, Lido's accounting oracle underreported. The daily rebase printed about 2.04% APR instead of the expected 2.15%, from a single 32 ETH deposit going unaccounted for. Nobody lost money, and Lido disclosed it the same day.
The mechanism their own engineers point to in the follow-up PRs is the Keys API, an off-chain indexer of on-chain key registrations. It served a stale, incomplete key set, so one Lido pubkey missed a filter and its deposit never made it into the reported pending balance. One of their PR comments puts the whole problem in a single clause. The keys come from live Keys API instances "(which still disagree)."
That's the exact layer this proof replaces. A stale key set has no valid form here. It fails the counter closure before anything else happens. An omission of that class can't pass silently.
Four buckets
Proving the set and proving the balances separately isn't enough; they have to be joined, and the join has to account for every key.
Each registered key maps to at most one beacon validator, injectively, and lands in one of four buckets:
- Matched - the key is a validator on the consensus layer, and we sum its balance
- Stranded - the validator exists but its withdrawal credential isn't Lido's
- Pending - deposited, not yet on the consensus layer, and proven against the beacon state's own pending-deposits queue rather than asserted
- Unaccounted - none of the above
The four counts must sum to the registered count. Since every bucket is published in the statement, a consumer can read unaccounted and decide what to do about it. It's an output of the proof, not something a prover can quietly zero out. Putting a key in the pending bucket requires opening it against the real queue in the anchored beacon state.

Lido's July upgrade replaced a contract-derived transient term with an oracle-asserted pending balance, and that bucket currently carries about 759,000 ETH with no exact check behind it.
One anchor
Both halves bind to a single beacon state root. The consensus-layer half proves against state.validators and state.balances; the execution-layer half proves against the execution payload header's state root, which lives inside that same beacon state. The pending-deposits queue comes from the same place.
That means "which validators are Lido's" and "what they hold" can't drift apart - they're two readings of one state, not two systems that have to agree.

We deliberately don't use EIP-4788 for anchoring. Every prior design in this space does, and EIP-4788 breaks under ePBS, which is one of the three reasons Lido gave for pausing its ZK oracle work in April. A single sync-committee-verified state root sidesteps that entirely, and any beacon light client can check it.
Hard forks are the other durability problem. The beacon state's field positions move, and a proof that silently reads the wrong field is worse than no proof. Our generalized indices aren't magic numbers - they're computed from the container layout, and compile-time checks hold that layout in place. Move one of the fields we read and the build stops with an error naming it. Known-answer tests against real mainnet state sit on top. A fork still takes a deliberate update on our side, and the checks are there to make that update hard to get wrong.
Cost
The report is 212 proofs - 143 covering the consensus-layer registry in slices, 69 covering the execution-layer operator ranges - aggregated into one proof that recursively verifies all of them, then wrapped down to a Groth16 proof that Ethereum checks for 350,184 gas.
Producing it took a few hours on four rented GPUs, for less than $30. For context, the brief the original grant program worked against asked for $200 and 30 minutes per report. One funded team landed near $1,000 and two hours.
We're not quoting a per-report price yet. This was a first production run, and it included the mistakes a first run includes. The recurring number will be lower, and we'd rather publish it once we've measured it than estimate it now.
What this covers, and what it doesn't
"Registered" means every key ever deposited through the staking modules. The registry keeps exited and fully-withdrawn keys forever, so the count is larger than the active validator count - and that's deliberate. Filtering to "active" would mean either trusting someone's definition of active or proving the filter, and a filter is a place to hide a validator. Enumerating everything and letting each balance speak removes that lever. The balance total comes out the same either way, since withdrawn validators hold nothing.
V3 stVaults keep their validators in unindexed mappings, about 0.07% of the protocol today, and are outside the enumerated set.
Per-module balance totals aren't separate fields in this statement yet. The module attribution is already proven - a key can't be attributed to the wrong module - so exposing per-module sums is a statement extension rather than new machinery. Given that Staking Router v3 made reporting per-module, that's the obvious next piece.
Next
The sanity checker Lido deployed with Staking Router v3 kept a slot for a second-opinion oracle, and it's still set to the zero address. We'd like to fill it - a proposal is going to the Lido research forum.
Everything here is open-source. The adversarial test suite is part of it, and the constructions that took the longest - the counter closure, the pending-queue binding, the four-bucket identity - are the ones we'd most like people to attack. If you find the shape we missed, we want to hear about it.
Thanks to the Lido contributors whose public PR discussion made the July mechanism legible enough to build against, and to th0rgal, whose forum question about verifying per-module balances framed the problem better than we had.
Questions, or want to dig into the construction? We're at info@willow.tech.
