perf(staking): O(1) operator stake aggregate + VPM share-pool slashing#134
Merged
Merged
Conversation
Two scalability/security improvements at the staking layer. Both eliminate
unbounded loops that would otherwise gas-bomb operators with many blueprints
or many delegators, making slashing and stake-accrual safe at production scale.
1) O(1) operator delegated-stake aggregate (`DelegationStorage`)
Before: `_getOperatorDelegatedStakeForAsset(operator, assetHash)` summed
`_rewardPools[op][h].totalAssets + Σ_bp _blueprintPools[op][bp][h].totalAssets`
by iterating every blueprint pool the operator joined — O(B) per call.
Called from the TWAP accrual hook on every delegate/undelegate AND from
subscription billing's `_accrueOperatorWeights`, so cost compounded.
After: `_operatorDelegatedAggregate[operator][assetHash]` holds the running
sum, updated at every pool-totalAssets mutation via `_increaseDelegatedStake`
/ `_decreaseDelegatedStake`. Read is a single SLOAD.
Mutation sites wired:
- `RewardsManager._updateAllModePool` / `_updateFixedModePools` (4 sites)
- `DelegationManagerLib._setDelegatorBlueprintPosition` (Fixed-mode
rebalance, 2 sites)
- `SlashingManager._slashAllModePool` / `_slashBlueprintPool` (2 sites)
Invariant `aggregate == rewardPool.totalAssets + Σ blueprintPool.totalAssets`
asserted by a new foundry invariant test that fuzzes 30,720 delegate /
undelegate / slash / reward combinations.
2) VPM share-pool delegator slashing (`ValidatorPodManager`)
Before: `_slash(operator, slashBps)` iterated `_operatorDelegators[operator]`
to proportionally debit each delegator's `delegations[d][op]` balance — O(D).
Slashing a popular operator with hundreds of delegators became gas-prohibitive
and eventually impossible.
After: each operator owns a `DelegationPool { totalAssets, totalShares }`.
Delegators hold shares (`_delegationShares[d][op]`), with virtual-shares /
virtual-assets offsets (`VIRTUAL_SHARES`, `VIRTUAL_ASSETS`) to defeat the
first-depositor inflation attack. Slash is a single SSTORE on `totalAssets`;
each delegator's effective claim drops proportionally via the new
share-to-asset conversion on read.
External ABI preserved:
- `delegations(d, op)` returns asset-denominated stake (now derived from
shares × totalAssets / totalShares).
- `operatorDelegatedStake(op)` returns `pool.totalAssets`.
- `OperatorPoolSlashed(operator, slashAmount, newTotalAssets, totalShares)`
replaces the per-delegator `DelegatorSlashed` event loop; indexers
recompute per-delegator impact off-chain from share balances + event.
Storage layout: new slots appended at the end of both `DelegationStorage`
(decrementing `__gap` 44 → 43) and `ValidatorPodManager` (no gap reorder).
No existing slots reordered — UUPS-safe.
Tests
- 30,720 fuzz calls on `invariant_operatorDelegatedAggregateMatchesPools`
- 3 new VPM invariant tests (pool-totals match net deposits; slash O(1);
ABI-preserved view returns equal share-to-asset conversion)
- Full regression: 1,454 / 1,454 tests pass (was 1,450; +4 new invariants)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two production-readiness scalability fixes at the staking layer. Both replace unbounded loops with O(1) accounting — making stake reads cheap inside the bill hot path and making slashing affordable for operators with hundreds of delegators.
1. O(1) operator delegated-stake aggregate
_getOperatorDelegatedStakeForAsset(op, assetHash)was O(B) — it summed_rewardPools[op][h].totalAssets + Σ_bp _blueprintPools[op][bp][h].totalAssetsby walking every blueprint the operator had joined. This call sits on hot paths:_accrueOperatorWeights, fired per (operator, asset) per billFor an operator on N blueprints, every read was N+1 SLOADs. Cost compounded inside the multi-asset bill loop.
Fix: maintain
_operatorDelegatedAggregate[op][assetHash]incrementally. Every pool-totalAssets mutation now pairs with_increaseDelegatedStake/_decreaseDelegatedStake. Reads are a single SLOAD.Mutation sites wired:
RewardsManager._updateAllModePool/_updateFixedModePools(4 sites)DelegationManagerLib._setDelegatorBlueprintPosition(2 sites, Fixed-mode rebalance)SlashingManager._slashAllModePool/_slashBlueprintPool(2 sites)Invariant:
aggregate == rewardPool.totalAssets + Σ blueprintPool.totalAssets. Asserted by a new foundry invariant test that fuzzes 30,720 calls of delegate/undelegate/slash/reward combinations — passes with zero reverts.2. VPM share-pool delegator slashing
ValidatorPodManager._slashwas O(D) — it walked_operatorDelegators[operator]to proportionally debit each delegator'sdelegations[d][op]balance and emitDelegatorSlashedper delegator. For an operator with hundreds of delegators slashing became gas-prohibitive; with thousands it eventually exceeds block gas limits.Fix: ERC4626-style share-pool. Each operator owns a
DelegationPool { totalAssets, totalShares }; delegators hold shares in_delegationShares[d][op]. Slash is now a single SSTORE onpool.totalAssets; every delegator's effective claim drops proportionally via share→asset conversion at read time.Inflation-attack mitigation:
VIRTUAL_SHARES/VIRTUAL_ASSETSoffsets in theMath.mulDivconversion paths (Solady/OpenZeppelin pattern) defeat the first-depositor attack.External ABI preserved:
delegations(d, op)selector unchanged; now returnsshares * (totalAssets + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES). Automatically reflects slashing.operatorDelegatedStake(op)returnspool.totalAssets.OperatorPoolSlashed(operator, slashAmount, newTotalAssets, totalShares)replaces the per-delegator emission loop. Indexers reconstruct per-delegator impact off-chain from share balances + event.Storage layout
UUPS-safe:
DelegationStorage.__gapdecremented 44 → 43; new_operatorDelegatedAggregatemapping appended at end.ValidatorPodManagerappended new state (_operatorDelegationPools,_delegationShares) at end; no existing slot reordered.Behavioral preservation
delegations(d, op)now correctly reflects slash impact without per-delegator state writes — a behavioral improvement, not a regression. Pre-PR slash updateddelegations[d][op]directly; post-PR slash dropstotalAssets, and the next read computes the slashed amount via conversion. Round-trip equivalent.Errors.*revert paths.Test plan
invariant_operatorDelegatedAggregateMatchesPools: 30,720 fuzz calls, 0 revertsProduction readiness checklist
Depends on / related
mainso no rebase needed.