Skip to content

perf(staking): O(1) operator stake aggregate + VPM share-pool slashing#134

Merged
drewstone merged 1 commit into
mainfrom
feat/staking-o1-aggregate-vpm-share-pool
May 15, 2026
Merged

perf(staking): O(1) operator stake aggregate + VPM share-pool slashing#134
drewstone merged 1 commit into
mainfrom
feat/staking-o1-aggregate-vpm-share-pool

Conversation

@tangletools
Copy link
Copy Markdown
Contributor

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].totalAssets by walking every blueprint the operator had joined. This call sits on hot paths:

  • TWAP accrual hook, fired on every delegate / undelegate
  • Subscription billing _accrueOperatorWeights, fired per (operator, asset) per bill

For 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._slash was O(D) — it walked _operatorDelegators[operator] to proportionally debit each delegator's delegations[d][op] balance and emit DelegatorSlashed per 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 on pool.totalAssets; every delegator's effective claim drops proportionally via share→asset conversion at read time.

Inflation-attack mitigation: VIRTUAL_SHARES / VIRTUAL_ASSETS offsets in the Math.mulDiv conversion paths (Solady/OpenZeppelin pattern) defeat the first-depositor attack.

External ABI preserved:

  • delegations(d, op) selector unchanged; now returns shares * (totalAssets + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES). Automatically reflects slashing.
  • operatorDelegatedStake(op) returns pool.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.__gap decremented 44 → 43; new _operatorDelegatedAggregate mapping appended at end.
  • ValidatorPodManager appended new state (_operatorDelegationPools, _delegationShares) at end; no existing slot reordered.

Behavioral preservation

  • Same external selectors, same return types.
  • delegations(d, op) now correctly reflects slash impact without per-delegator state writes — a behavioral improvement, not a regression. Pre-PR slash updated delegations[d][op] directly; post-PR slash drops totalAssets, and the next read computes the slashed amount via conversion. Round-trip equivalent.
  • Same Errors.* revert paths.

Test plan

  • Full regression: 1,454 / 1,454 tests pass (+4 new invariants over baseline 1,450)
  • New invariant invariant_operatorDelegatedAggregateMatchesPools: 30,720 fuzz calls, 0 reverts
  • New VPM invariants (pool totals match net deposits, slash is O(1) gas-bounded, ABI views match share conversions)
  • CI green
  • Deploy to base-sepolia and slash an operator with ≥100 delegators; verify gas is constant w.r.t. delegator count

Production readiness checklist

  • No storage layout changes that would break existing deployments
  • No external ABI changes
  • No new audit-round tags or session identifiers in code
  • Invariant tests cover the new accounting in adversarial fuzzing
  • Inflation-attack mitigation via virtual offsets

Depends on / related

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)
@drewstone drewstone merged commit 6653ec0 into main May 15, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants