Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
544 changes: 248 additions & 296 deletions src/beacon/ValidatorPodManager.sol

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions src/staking/DelegationManagerLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,14 @@ abstract contract DelegationManagerLib is OperatorManager {
}

if (newAmount > currentAmount) {
pool.totalAssets += newAmount - currentAmount;
uint256 delta = newAmount - currentAmount;
pool.totalAssets += delta;
_increaseDelegatedStake(operator, assetHash, delta);
} else if (currentAmount > newAmount) {
uint256 deltaAmount = currentAmount - newAmount;
pool.totalAssets = deltaAmount > pool.totalAssets ? 0 : pool.totalAssets - deltaAmount;
uint256 applied = deltaAmount > pool.totalAssets ? pool.totalAssets : deltaAmount;
pool.totalAssets -= applied;
_decreaseDelegatedStake(operator, assetHash, applied);
}

_delegatorBlueprintShares[delegator][operator][assetHash][blueprintId] = newShares;
Expand Down
45 changes: 33 additions & 12 deletions src/staking/DelegationStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -311,24 +311,37 @@ abstract contract DelegationStorage {

/// @notice Operator's total delegated stake for a specific asset (sum across
/// All-mode pool and every Fixed-mode blueprint pool the operator owns).
/// @dev Defined here (not in DelegationManagerLib) so OperatorManager can also
/// compute total stake-for-asset when its self-stake mutations need to
/// accrue stake-seconds. All inputs live in DelegationStorage.
/// @dev Reads the incrementally-maintained `_operatorDelegatedAggregate` so cost
/// is a single SLOAD rather than O(blueprints). Every callsite that mutates
/// `_rewardPools[op][h].totalAssets` or `_blueprintPools[op][bp][h].totalAssets`
/// MUST keep the aggregate in sync via `_increaseDelegatedStake` /
/// `_decreaseDelegatedStake`; otherwise the invariant
/// `aggregate == rewardPool.totalAssets + Σ blueprintPool.totalAssets` breaks.
function _getOperatorDelegatedStakeForAsset(
address operator,
bytes32 assetHash
)
internal
view
returns (uint256 total)
returns (uint256)
{
total += _rewardPools[operator][assetHash].totalAssets;
return _operatorDelegatedAggregate[operator][assetHash];
}

uint256 bpCount = _operatorBlueprints[operator].length();
for (uint256 i = 0; i < bpCount; i++) {
uint64 blueprintId = uint64(_operatorBlueprints[operator].at(i));
total += _blueprintPools[operator][blueprintId][assetHash].totalAssets;
}
/// @notice Apply a positive delta to the operator's delegated-stake aggregate.
function _increaseDelegatedStake(address operator, bytes32 assetHash, uint256 amount) internal {
if (amount == 0) return;
_operatorDelegatedAggregate[operator][assetHash] += amount;
}

/// @notice Apply a negative delta to the operator's delegated-stake aggregate.
/// @dev Saturating subtraction guards against rounding edges in share-pool conversions
/// where the per-pool `totalAssets -= amount` already saturates to zero. Without a
/// floor here, the aggregate could underflow while the pool stayed at zero.
function _decreaseDelegatedStake(address operator, bytes32 assetHash, uint256 amount) internal {
if (amount == 0) return;
uint256 current = _operatorDelegatedAggregate[operator][assetHash];
_operatorDelegatedAggregate[operator][assetHash] = current > amount ? current - amount : 0;
}

/// @notice Operator's total stake for an asset (self-stake when bond + delegated).
Expand Down Expand Up @@ -468,8 +481,16 @@ abstract contract DelegationStorage {
/// without contributing area, so pre-existing pools begin TWAP at upgrade.
mapping(address => mapping(bytes32 => uint64)) internal _cumStakeSecondsLastUpdate;

/// @notice O(1) running total of an operator's delegated stake per asset.
/// @dev Invariant: equals `_rewardPools[op][h].totalAssets +
/// Σ_bp _blueprintPools[op][bp][h].totalAssets` after every state-modifying call.
/// Maintained incrementally by `_increaseDelegatedStake` / `_decreaseDelegatedStake`
/// at every pool mutation site (delegate, undelegate, slash). Lets the TWAP
/// accrual hook and billing read total delegated stake in a single SLOAD instead
/// of iterating the operator's blueprint set.
mapping(address operator => mapping(bytes32 assetHash => uint256)) internal _operatorDelegatedAggregate;

/// @notice Reserved storage gap for future upgrades
/// @dev Standard gap size is 50 slots. When adding new storage, decrease this gap accordingly.
/// @dev F5 added 2 mappings; gap reduced by 2 (46 → 44).
uint256[44] private __gap;
uint256[43] private __gap;
}
10 changes: 8 additions & 2 deletions src/staking/RewardsManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,12 @@ abstract contract RewardsManager is DelegationManagerLib {
if (isIncrease) {
pool.totalShares += shares;
pool.totalAssets += amount;
_increaseDelegatedStake(operator, assetHash, amount);
} else {
pool.totalShares = shares > pool.totalShares ? 0 : pool.totalShares - shares;
pool.totalAssets = amount > pool.totalAssets ? 0 : pool.totalAssets - amount;
uint256 applied = amount > pool.totalAssets ? pool.totalAssets : amount;
pool.totalAssets -= applied;
_decreaseDelegatedStake(operator, assetHash, applied);
}
}

Expand Down Expand Up @@ -176,9 +179,12 @@ abstract contract RewardsManager is DelegationManagerLib {
if (isIncrease) {
pool.totalShares += sharesForBlueprint;
pool.totalAssets += amountForBlueprint;
_increaseDelegatedStake(operator, assetHash, amountForBlueprint);
} else {
pool.totalShares = sharesForBlueprint > pool.totalShares ? 0 : pool.totalShares - sharesForBlueprint;
pool.totalAssets = amountForBlueprint > pool.totalAssets ? 0 : pool.totalAssets - amountForBlueprint;
uint256 applied = amountForBlueprint > pool.totalAssets ? pool.totalAssets : amountForBlueprint;
pool.totalAssets -= applied;
_decreaseDelegatedStake(operator, assetHash, applied);
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/staking/SlashingManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ abstract contract SlashingManager is RewardsManager {
pool.totalAssets = 0;
}

_decreaseDelegatedStake(operator, assetHash, slashed);

// That's it! No iteration needed.
// All mode delegator balances are now effectively reduced because
// their shares are worth less at the new exchange rate.
Expand Down Expand Up @@ -532,6 +534,8 @@ abstract contract SlashingManager is RewardsManager {
pool.totalAssets = 0;
}

_decreaseDelegatedStake(operator, assetHash, slashed);

// Fixed mode delegators for this blueprint now have reduced balance
// because their shares in this pool are worth less.
}
Expand Down
135 changes: 135 additions & 0 deletions test/beacon/ValidatorPodManagerTest.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -883,4 +883,139 @@ contract ValidatorPodManagerTest is BeaconTestBase {
assertEq(podManager.MAX_WITHDRAWAL_DELAY(), 1_296_000, "Max delay ~30 days");
assertEq(podManager.withdrawalDelayBlocks(), 302_400, "Initial delay is default");
}

// ═══════════════════════════════════════════════════════════════════════════
// DELEGATION SHARE-POOL SEMANTICS
// ═══════════════════════════════════════════════════════════════════════════

/// @notice After a slash, every delegator's effective claim drops proportionally
/// even though no per-delegator storage was written. This is the central
/// correctness property of the O(1) share-pool slash.
function test_slashAffectsAllDelegatorsProportionally() public {
_registerOperator(operator1, 1 ether);

address[5] memory delegators = [makeAddr("d0"), makeAddr("d1"), makeAddr("d2"), makeAddr("d3"), makeAddr("d4")];
uint256[5] memory amounts = [uint256(10 ether), 20 ether, 5 ether, 15 ether, 50 ether];

uint256 totalDelegated;
for (uint256 i = 0; i < delegators.length; i++) {
address pod = address(_createPodWithShares(delegators[i], amounts[i]));
assertTrue(pod != address(0));
vm.prank(delegators[i]);
podManager.delegateTo(operator1, amounts[i]);
totalDelegated += amounts[i];
}

assertEq(
podManager.getOperatorDelegatedStake(operator1),
totalDelegated,
"pool totalAssets equals sum of delegate inputs (initial 1:1 rate)"
);

// Snapshot pre-slash live valuations
uint256[5] memory before_;
for (uint256 i = 0; i < delegators.length; i++) {
before_[i] = podManager.getDelegation(delegators[i], operator1);
assertEq(before_[i], amounts[i], "pre-slash live value equals input");
}

// 25% slash applied to the operator
vm.prank(slasher);
podManager.slash(operator1, 1, 2500, keccak256("evidence"));

// Slash math: amount = (selfStake + delegated) * bps / 10_000.
// Self-stake (1 ether) absorbs first; the remainder hits the pool.
uint256 totalAmount = (1 ether + totalDelegated) * 2500 / 10_000;
uint256 selfSlash = totalAmount > 1 ether ? 1 ether : totalAmount;
uint256 poolSlash = totalAmount - selfSlash;
uint256 expectedPoolAfter = totalDelegated - poolSlash;
assertEq(
podManager.getOperatorDelegatedStake(operator1),
expectedPoolAfter,
"pool totalAssets drops by the delegated portion of the slash"
);

// Each delegator's live value should be ~ totalAssetsAfter/totalAssetsBefore * before.
// The virtual offset biases convertToAssets upward by at most ~VIRTUAL_ASSETS
// distributed across shareholders; with our amounts that's well under 1e6 wei.
uint256 totalAssetsBefore = totalDelegated;
uint256 totalAssetsAfter = expectedPoolAfter;
for (uint256 i = 0; i < delegators.length; i++) {
uint256 live = podManager.getDelegation(delegators[i], operator1);
uint256 expected = (before_[i] * totalAssetsAfter) / totalAssetsBefore;
uint256 diff = live > expected ? live - expected : expected - live;
assertLe(diff, 1e6, "per-delegator slash within virtual-offset dust");
}
}

/// @notice Gas used by `_slash` must be bounded -- not grow with the number of delegators.
function test_slashGas_BoundedRegardlessOfDelegatorCount() public {
_registerOperator(operator1, 1 ether);
_registerOperator(operator2, 1 ether);

// Operator 1 gets 1 delegator
address d = makeAddr("solo");
_createPodWithShares(d, 5 ether);
vm.prank(d);
podManager.delegateTo(operator1, 5 ether);

// Operator 2 gets 50 delegators
for (uint256 i = 0; i < 50; i++) {
address di = makeAddr(string(abi.encodePacked("crowd-", vm.toString(i))));
_createPodWithShares(di, 5 ether);
vm.prank(di);
podManager.delegateTo(operator2, 5 ether);
}

vm.prank(slasher);
uint256 gasA0 = gasleft();
podManager.slash(operator1, 1, 1000, bytes32("a"));
uint256 gasA = gasA0 - gasleft();

vm.prank(slasher);
uint256 gasB0 = gasleft();
podManager.slash(operator2, 2, 1000, bytes32("b"));
uint256 gasB = gasB0 - gasleft();

// The 50-delegator slash must not cost meaningfully more than the 1-delegator slash.
// 5x is a generous ceiling for warm-vs-cold storage variance; a legacy O(D) loop
// would scale ~30-40x at this size.
emit log_named_uint("gas slash op1 (1 delegator)", gasA);
emit log_named_uint("gas slash op2 (50 delegators)", gasB);
assertLt(gasB, gasA * 5, "slash gas does not scale with delegator count");
}

/// @notice Invariant: for every operator, the operator delegation pool's totalAssets
/// equals the live sum of per-delegator asset valuations (within rounding dust
/// introduced by virtual offsets and Floor rounding).
function test_invariant_poolTotalAssetsMatchesSumOfDelegations() public {
_registerOperator(operator1, 1 ether);

address[3] memory dels = [makeAddr("ia"), makeAddr("ib"), makeAddr("ic")];
uint256[3] memory ams = [uint256(7 ether), 13 ether, 21 ether];

for (uint256 i = 0; i < dels.length; i++) {
_createPodWithShares(dels[i], ams[i]);
vm.prank(dels[i]);
podManager.delegateTo(operator1, ams[i]);
}

// Apply a 33% slash
vm.prank(slasher);
podManager.slash(operator1, 1, 3333, bytes32("inv"));

uint256 sumLive;
for (uint256 i = 0; i < dels.length; i++) {
sumLive += podManager.getDelegation(dels[i], operator1);
}

uint256 totalAssets = podManager.getOperatorDelegatedStake(operator1);

// sumLive may exceed or undershoot totalAssets within bounded dust set by the
// virtual offset (VIRTUAL_ASSETS) plus per-delegator Floor rounding (~1 wei each).
// The point of the invariant is that the bound is constant in delegator count,
// not that the sum equals totalAssets exactly.
uint256 diff = sumLive > totalAssets ? sumLive - totalAssets : totalAssets - sumLive;
assertLt(diff, 1e6, "sum vs pool totalAssets is bounded dust (not delegator-count scaled)");
}
}
17 changes: 16 additions & 1 deletion test/staking/SlashAccountingInvariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import { StakingAdminFacet } from "../../src/facets/staking/StakingAdminFacet.so

contract MultiAssetDelegationInvariantExposed is StakingFacetBase, IFacetSelectors {
function selectors() external pure returns (bytes4[] memory selectorList) {
selectorList = new bytes4[](3);
selectorList = new bytes4[](4);
selectorList[0] = this.rewardPoolTotals.selector;
selectorList[1] = this.blueprintPoolTotals.selector;
selectorList[2] = this.operatorStake.selector;
selectorList[3] = this.delegatedAggregate.selector;
}

function rewardPoolTotals(address operator) external view returns (uint256) {
Expand All @@ -39,6 +40,11 @@ contract MultiAssetDelegationInvariantExposed is StakingFacetBase, IFacetSelecto
function operatorStake(address operator) external view returns (uint256) {
return _operatorMetadata[operator].stake;
}

function delegatedAggregate(address operator) external view returns (uint256) {
bytes32 assetHash = keccak256(abi.encode(Types.AssetKind.Native, address(0)));
return _operatorDelegatedAggregate[operator][assetHash];
}
}

contract SlashAccountingHandler is Test {
Expand Down Expand Up @@ -191,6 +197,15 @@ contract SlashAccountingInvariantTest is StdInvariant, Test {
assertEq(actualTotal, handler.expectedTotal(), "slashable accounting drifted from modeled total");
}

/// @notice O(1) delegated-stake aggregate must equal the manual sum across the All-mode
/// pool and every Fixed-mode blueprint pool. Verifies that delegate, undelegate,
/// and slash paths all keep `_operatorDelegatedAggregate` in sync with the
/// underlying pool totals it summarizes.
function invariant_operatorDelegatedAggregateMatchesPools() public view {
uint256 manual = exposed.rewardPoolTotals(operator) + exposed.blueprintPoolTotals(operator, BLUEPRINT_ID);
assertEq(exposed.delegatedAggregate(operator), manual, "delegated aggregate diverged from pool sum");
}

function invariant_cumulativeSlashedNeverExceedsInitialPlusDeposits() public view {
uint256 actualTotal =
exposed.operatorStake(operator) + exposed.rewardPoolTotals(operator) + exposed.blueprintPoolTotals(operator, BLUEPRINT_ID);
Expand Down
Loading