ELIP-019: Queued-Slash Share Accounting Fix — Implementation Complete
| Author(s) | ELHAJIN (amin), Nadir Akhtar (Eigen Labs) |
|---|---|
| Created | [upgrade completion date] |
| Proposal | ELIP-019 |
| Status | Shipped (v1.13.1) |
Following our advance notice post last month, ELIP-019 has now executed as release v1.13.1. This post covers the change in full, then explains why we followed the upgrade pattern we did and how the upgrade itself was carried out.
The Change
Background
When a delegated staker queues a withdrawal, their shares remain slashable for MIN_WITHDRAWAL_DELAY_BLOCKS (~14 days on mainnet, measured in blocks) so an operator can’t dodge a pending slash.
To get into the math: DelegationManager tracks this by recording, in _cumulativeScaledSharesHistory, the scaled shares entering an operator’s slashable withdrawal queue. If a slash lands while withdrawals are still queued, _getSlashableSharesInQueue re-derives how much of the queue is still slashable by re-multiplying the recorded cumulative total by the operator’s current magnitude. That reconstructed figure feeds totalDepositSharesToSlash and, from there, increaseBurnOrRedistributableShares — the actual amount burned or redistributed.
This accounting only holds together if the recorded queue figure and the actual backing removed from the operator are equal. ELIP-019 fixes a case where they weren’t.
The problem
The queue recorded the raw scaleForQueueWithdrawal value — floor(depositShares * scalingFactor) — a single flooring operation. But the backing actually removed from the operator via _decreaseDelegation is withdrawableShares = calcWithdrawable(depositShares, slashingFactor), which re-multiplies that scaled value by the operator’s magnitude and floors a second time. The over-count comes down to a single rounding mismatch in how those per-staker amounts are aggregated.
Floor-of-sum vs. sum-of-floors. At slash time, reconstruction re-multiplies the cumulative recorded total by magnitude and floors once — floor(Σ scaledSharesᵢ · magnitude). The backing actually removed is instead a sum of per-staker floors, Σ floor(scaledSharesᵢ · magnitude), because each staker’s withdrawable amount was floored individually at the moment its withdrawal was queued. Since floor(Σ xᵢ) ≥ Σ floor(xᵢ), the reconstructed figure sits at or above the true backing, and the gap grows by up to one wei per additional queued withdrawal in the window — counted per queue entry against the operator in this strategy, not per distinct staker, so one staker queuing several withdrawals contributes several terms. For a single queued withdrawal the two coincide exactly — the divergence is purely an artifact of summing many independently-floored values.
The net effect was a wei-scale over-burn / over-redistribution on slash: dust-sized on any single event, but scaling with the number of withdrawals queued against the operator at the time, and in violation of an invariant the burn/redistribution path depends on holding exactly: that the amount reconstructed as still-slashable in the queue should never exceed what was actually removed from the operator.
The impact
Left unfixed, this dust is the seed of a theoretical attack on strategy solvency — worth walking through in full, because it’s both the reason the fix matters and the reason we handled disclosure the way we did.
Let’s trace the over-count to its end. The excess slashable shares flow through slashOperatorShares → increaseBurnOrRedistributableShares into the burn/redistribution accounting, and at slash resolution clearBurnOrRedistributableShares → StrategyBase.withdraw removes real tokens and the corresponding shares from the strategy. The excess is not socialized pro rata: other stakers retain their full share claims, but the strategy now has fewer totalShares than all claims require. Withdrawals can continue until a staker — typically the last — or a burn/redistribute tries to withdraw more shares than remain, at which point StrategyBase.withdraw reverts. For example, if a strategy has 100 shares — 40 attributable to a slashed operator and 60 held by another staker — an over-counted burn or redistribution of 41 leaves only 59 totalShares, while the other staker still holds a claim for 60. Their withdrawal of full amount therefore reverts. Because the gap is only dust, remediation is straightforward: a compensating deposit that mints the missing shares and leaves them in the strategy allows the affected last staker(s) to exit. In a redistributing operator set, the excess underlying goes to the redistribution recipient; in a pure-burn set, it is destroyed.
Why it stays theoretical, not practical. With n queued entries, a slash can over-count by at most n wei, so extracting a meaningful sum requires manufacturing a very large number of independently floored queue events, each backed by real shares and carrying an incremental gas cost, and landing a slash while those entries remain inside the ~14-day slashable withdrawal window. Nothing in the code structurally caps repetition, but the capital and gas costs dominate the recoverable dust. This was a correctness defect that had to be fixed at the source, not in anyway a profitable attack.
Why we still embargoed the mechanism. Our own analysis is what tells us the attack is economically self-defeating — and “we ran the numbers and it isn’t worth it” is exactly the kind of conclusion a novel amplification can overturn. If someone found a way to widen the per-event gap, drive the cost of manufacturing queued withdrawals toward zero, or compose this bug with an unexpected quirk of a particular strategy’s accounting, the economics could move. Publishing the precise rounding gap before the fix was live would have handed that search its starting point, during the exact window the bug was still open. Holding the mechanism until execution is standard responsible-disclosure practice for accounting bugs — sized to the possibility that the practical ceiling is higher than we found, not to the wei-scale effect we actually measured.
The fix
The fix is a one-line change in _removeSharesAndQueueWithdrawal. Rather than recording the raw scaled value, we record the queued slashable shares by inverting the exact quantity that was actually removed from the operator:
// Before: raw scaled shares, skips the magnitude round-trip
_addQueuedSlashableShares(operator, strategies[i], scaledShares[i]);
// After: invert withdrawableShares (the exact amount removed via _decreaseDelegation)
uint256 slashableScaledShares =
slashingFactors[i] == 0 ? 0 : withdrawableShares[i].divWad(slashingFactors[i]);
_addQueuedSlashableShares(operator, strategies[i], slashableScaledShares);
The slashingFactors[i] == 0 guard covers a fully-slashed operator (magnitude 0): there’s nothing left in the queue to slash, and the guard avoids a division by zero. Re-multiplying this recorded inverse by magnitude at slash time now reconstructs no more than what was actually removed, by construction. The change is scoped to this one internal accounting path — no interface, storage-layout, or event changes.
Audit and residual risk
An external auditing firm (Certora) reviewed and approved the fix; the LST/ERC-20 accounting path is confirmed correct. The full report is included in this release’s audit directory (audits/Certora - Eigenlayer - DelegationManager Upgrade - PR #84 - Report.pdf).
Validation. The invariant under test is queueSlashable ≤ removedFromOperator — the queue must never claim more slashable shares than were actually removed from the operator — and it’s exercised with exact-wei bounds, not approximations. DelegationUnit.t.sol::test_slashOperatorShares_DoesNotDoubleCountRoundedQueueDust demonstrates the bug and confirms the fix in the same test: a 100-staker slash → queue → slash scenario where, under the old accounting, the second slash re-counted the same rounded-down dust, while with the fix getSlashableSharesInQueue returns only backed shares and the total burned never exceeds what was delegated. testFuzz_slashOperatorShares_QueuedSharesAreBackedByRemovedShares extends this with unit-level fuzzing, asserting queueSlashable ≤ removedFromOperator across randomized magnitudes and staker counts, and the Integration_QueueSlashAccounting suite (QueueSlashAccounting.t.sol) exercises the same bound end-to-end across randomized deposit → slash → queue → (redelegate →) slash flows. We also confirmed _cumulativeScaledSharesHistory has exactly one writer (_addQueuedSlashableShares, a single call site), so there’s no sibling code path still recording shares the old way — the fix is complete across the LST/ERC-20 path, not partial.
Two further residuals are worth surfacing to the community directly, rather than leaving them implicit:
Beacon chain asymmetry — accepted, known, and pre-existing. The invariant can still be violated for beaconChainETHStrategy when 0 < beaconChainSlashingFactor < 1: the queue records by dividing by the full slashingFactor = maxMagnitude · beaconChainSlashingFactor, but _getSlashableSharesInQueue re-multiplies by maxMagnitude alone — the beacon factor doesn’t cancel out. This asymmetry predates ELIP-019 and was already known to the team. It is currently inert: beacon-slashed shares only accumulate in EigenPodManager.burnableETHShares, a value that’s written but never consumed, meaning beacon-share burning is effectively disabled today. If beacon burning is ever enabled, this asymmetry must be closed first.
Transition window — self-healing, no action needed. The fix is non-retroactive. Immediately after the upgrade, _cumulativeScaledSharesHistory briefly held a mix of old-formula and new-formula entries, until MIN_WITHDRAWAL_DELAY_BLOCKS elapsed and the old entries aged out. This self-healed with no new risk introduced during the window. Auditors reviewed this transition and declined to recommend a pause, given the wei-scale magnitude of the effect being transitioned away from.
No under-slashing. We confirmed the fix doesn’t open an under-slashing path in the other direction: withdrawal completion computes sharesToWithdraw from the same rounded scaledShares · slashingFactor path used elsewhere, so a staker can’t withdraw value that was skipped at queue time.
Why We Followed This Upgrade Pattern
Beyond holding the technical writeup until execution, the deployment itself was shaped to keep the fixed code confidential until the upgrade was live — shrinking the window between when the bug becomes discoverable and when it’s closed to essentially nothing. There was nothing for a staker, operator, or integrator to do differently before the upgrade regardless, so holding the detail cost the community nothing it could have acted on.
Our more typical pattern deploys the new implementation early, then upgrades the proxy to it after the timelock. For a routine change that’s fine. For a security fix it has a sharp downside: the fixed bytecode sits on-chain, diff-able against the old implementation, for the whole timelock window — and throughout that window the unfixed contract is still the live one. That effectively advertises the vulnerability at the one moment it’s both discoverable and still exploitable.
The deploy-at-execution pattern we used instead (detailed in the next section) keeps no code at the implementation address until the upgrade transaction itself, so the fixed source never exists on-chain in a discoverable-but-inactive state. Exposure and remediation land in the same atomic action. All that’s public during the timelock is that an upgrade to DelegationManager is queued and the address it will target — never the code that address will eventually hold.
We did this within our standard three-phase process rather than an expedited one: the severity didn’t warrant bypassing the timelock, and deploy-at-execution let us keep the timelock’s full review window without paying the confidentiality cost an early-deployed implementation carries.
How We Did the Upgrade
The upgrade shipped as a standard three-phase EigenLayer proxy upgrade — EOA deploy, multisig queue, multisig completion after timelock — managed through our zeus deployment tooling, and moved the protocol from v1.13.0 to v1.13.1. DelegationManager is the only contract that changed; ProtocolRegistry’s semantic version was bumped to 1.13.1 alongside it.
One part of this deployment departs from our usual pattern: the new implementation’s bytecode wasn’t deployed ahead of time. Instead, its CREATE2 address was precomputed and pinned during phase 1, and the bytecode itself was deployed atomically with the upgrade during phase 3 — a deliberate “deploy-at-execution” pattern, rather than our more typical “deploy the implementation early, upgrade to it later.”
Phase 1 — precompute and pin (EOA). We computed the CREATE2 address for the new implementation from its creation code and the mainnet constructor arguments, and confirmed it matched a hardcoded pin, 0x6a8BEd4062C895130E2d09bA442D3eCEAd5Df6c2. A separate check confirmed the pinned creation code matched this build’s freshly compiled DelegationManager bytecode byte-for-byte, aside from the compiler’s own metadata tail, so the pin can’t silently drift if the source changes. This phase registered the address; it deployed no bytecode, by design — no code exists at that address until phase 3.
Phase 2 — queue (multisig, ops multisig). The upgrade was queued in the protocol timelock, scheduling a single operation that, once executed, runs two calls: upgrading the DelegationManager proxy to the pinned implementation address, and bumping ProtocolRegistry to 1.13.1. The queue used the timelock’s standard minimum delay. The implementation remained undeployed throughout this phase; signers independently verified the pinned target address before signing.
Phase 3 — execute (multisig, protocol council multisig, after the timelock delay). In a single multisig action, we re-validated the pinned creation code, deployed DelegationManager via CREATE2 at the precomputed salt — landing the bytecode at exactly the pinned address — and executed the queued timelock operation. The proxy now points at the freshly deployed, audited implementation, and ProtocolRegistry reads 1.13.1. Post-execution, we validated that contract version, paused status, minWithdrawalDelayBlocks, constructor immutables, proxy admin, and registry state were all exactly as expected.
Why this matters for trust, not just process. A CREATE2 address is a pure function of the deploying factory, a salt, and the hash of the contract’s init code — only one init code can ever occupy a given pinned address. That means if the audited source is what compiles to the pinned address, no other bytecode, including anything an insider might attempt to substitute, can occupy that address instead. And if substitute bytecode were deployed to some other address, the pinned address would simply be empty — at which point the proxy upgrade itself reverts, since it requires the new implementation to be a deployed contract. The guarantee here comes from CREATE2 determinism and a fixed upgrade target, not from any single script-level check along the way.
No interface, storage-layout, or event changes were involved at any phase, which kept the upgrade a drop-in replacement for integrators — nothing to re-point, re-approve, or migrate.
Reference values. For anyone who wants to independently confirm the deployed bytecode matches what’s described here:
| Item | Value |
|---|---|
| Pinned implementation | 0x6a8BEd4062C895130E2d09bA442D3eCEAd5Df6c2 |
| keccak256(creationCode) | 0x6eba6c2e4abc46d43d97e8a72316abd366479039d5ef9489e84d897227fddea8 |
| keccak256(initCode) | 0x4640c073aa3a0b90cd5fa504d551c270ca627883ee1888ec1c017e142d0f24d5 |
| CreateX guarded salt | 0x00887e47e058bddaf60a458ddd2b033f8b2407561e41b83629daa625c3a7666f |
| CreateX factory | 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed |
| Deployer (protocol council multisig) | 0x461854d84ee845f905e0ecf6c288ddeeb4a9533f |
| Toolchain | forge 1.5.1 · solc 0.8.30 · optimizer 200 runs · via_ir off |
Mainnet constructor args (StrategyManager, EigenPodManager, AllocationManager, PauserRegistry, PermissionController, MIN_WITHDRAWAL_DELAY, version):
0x858646372CC42E1A627fcE94aa7A7033e7CF075A // StrategyManager
0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338 // EigenPodManager
0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39 // AllocationManager
0xB8765ed72235d279c3Fb53936E4606db0Ef12806 // PauserRegistry
0x25E5F8B1E7aDf44518d35D5B2271f114e081f0E5 // PermissionController
100800 // MIN_WITHDRAWAL_DELAY
"1.13.1" // version
These values are only reproducible against the pinned toolchain above — a different compiler or optimizer configuration will legitimately produce a different address.
Questions and follow-ups welcome in the replies.