datahaven/specs/validator-set-selection/validator-set-selection.md

248 lines
10 KiB
Markdown
Raw Permalink Normal View History

feat: implement weighted top-32 validator selection (#443) ## Overview Implements deterministic weighted-stake-based validator selection in `DataHavenServiceManager`, building on the era-targeting submitter model from PR #433. Previously, `buildNewValidatorSetMessage()` forwarded all registered operators in arbitrary membership order with no stake-based ranking, meaning high-stake operators could be displaced by lower-stake ones when downstream caps applied. This PR fixes that by computing a weighted stake score per operator and selecting the top-32 candidates before bridging the set to DataHaven. Spec: `specs/validator-set-selection/validator-set-selection.md` ## Contract Changes (`DataHavenServiceManager.sol`) **New state:** - `MAX_ACTIVE_VALIDATORS = 32` — cap on the outbound validator set - `mapping(IStrategy => uint96) public strategiesAndMultipliers` — per-strategy weight used in the selection formula **Updated `buildNewValidatorSetMessage()`:** 1. Fetches allocated stake for all operators × strategies from `AllocationManager` 2. Computes `weightedStake(op) = Σ(allocatedStake[op][j] × multiplier[j])` across all strategies 3. Filters operators with no solochain address mapping or zero weighted stake 4. Runs a partial selection sort to pick the top `min(candidateCount, 32)` by descending weighted stake; ties broken by lower operator address (deterministic) 5. Reverts with `EmptyValidatorSet()` if no eligible candidates remain **Admin API changes:** - `addStrategiesToValidatorsSupportedStrategies()` signature changed from `IStrategy[]` to `IRewardsCoordinatorTypes.StrategyAndMultiplier[]` — strategy and multiplier are stored atomically in one call, eliminating the risk of a strategy being registered without a multiplier - New `setStrategiesAndMultipliers(StrategyAndMultiplier[])` — updates multiplier weights for existing strategies without touching the EigenLayer strategy set - New `getStrategiesAndMultipliers()` — returns all strategies with their current multipliers - `removeStrategiesFromValidatorsSupportedStrategies()` now cleans up multiplier entries on removal **New error / event:** - `EmptyValidatorSet()` — reverts when no eligible candidates exist - `StrategiesAndMultipliersSet(StrategyAndMultiplier[])` — emitted on add or update of multipliers ## Tests (`ValidatorSetSelection.t.sol`) New 552-line Foundry test suite covering all cases from the spec: | Case | |------| | `addStrategies` stores multiplier atomically | | `removeStrategies` deletes multiplier | | `setStrategiesAndMultipliers` updates without touching the strategy set | | `getStrategiesAndMultipliers` returns correct pairs | | Weighted stake computed correctly across multiple strategies | | Operators with zero weighted stake are excluded | | Unset multiplier treated as 0 | | Top-32 selection when candidate count > 32 | | All candidates included when count < 32 | | Tie-breaking by lower operator address | | `EmptyValidatorSet` revert when no eligible operators | ## Deploy Scripts - **`DeployBase.s.sol`**: Sets a default multiplier of `1` for all configured validator strategies after AVS registration via `setStrategiesAndMultipliers` - **New `AllocateOperatorStake.s.sol`**: Forge script that allocates full magnitude (`1e18`) to the validator operator set for a given operator. Must be run at least one block after `SignUpValidator` to respect EigenLayer's allocation configuration delay. ## E2E Framework - **`validators.ts` — `registerOperator()`**: Extended to deposit tokens into each deployed strategy and allocate full magnitude to the DataHaven operator set after registration. Previously operators registered without staking, producing zero weighted stake and getting filtered out by the new selection logic. - **`setup-validators.ts`**: Added a stake allocation pass after the registration loop, invoking `AllocateOperatorStake.s.sol` per validator. - **`validator-set-update.test.ts`**: Added debug logging for transaction receipts and the `OutboundMessageAccepted` / `ExternalValidatorsSet` events. - **`generated.ts`**: Regenerated contract bindings to include new functions, events, and the `EmptyValidatorSet` error. ## ⚠️ Breaking Changes ⚠️ - `addStrategiesToValidatorsSupportedStrategies(IStrategy[])` → `addStrategiesToValidatorsSupportedStrategies(StrategyAndMultiplier[])`: callers must supply multipliers alongside strategies. - Operators with zero weighted stake are no longer included in the bridged validator set. ## Rollout Notes 1. PR #433 (era-targeting + submitter role) must be deployed first 2. Deploy this `ServiceManager` upgrade 3. Confirm `strategiesAndMultipliers` is set for all active strategies (default multiplier `1` applied automatically by `DeployBase`) 4. Deploy the runtime cap-enforcement changes (spec section 10.2) 5. Submitter daemon requires no changes — continues submitting `targetEra = ActiveEra + 1`
2026-02-24 08:23:57 +00:00
# Validator Set Selection Specification
## Top-32 by Weighted Stake (Continuation of PR #433)
- Status: Draft
- Owners: DataHaven Team
- Last Updated: February 12, 2026
- Depends on: PR #433 (`feat: automated validator set submission with era targeting`)
## 1. Summary
PR #433 introduced era-targeted validator-set submission with a dedicated submitter role and runtime era validation. This spec is a continuation of that work.
This document adds deterministic weighted-stake selection so the outbound validator set is ranked before it is bridged:
1. Ethereum computes weighted stake per operator.
2. Ethereum deterministically sorts operators and selects top candidates.
3. DataHaven enforces a final total active authority cap of 32 after combining whitelisted and external validators.
The era-targeting model from PR #433 remains unchanged.
## 2. Baseline From PR #433
This spec assumes the following behavior already exists:
1. `DataHavenServiceManager.sendNewValidatorSetForEra(uint64 targetEra, ...)` is used for submission.
2. Submission is restricted to `validatorSetSubmitter` (`onlyValidatorSetSubmitter`).
3. `external_index` in the Snowbridge payload is the `targetEra`.
4. DataHaven runtime enforces era validity (`targetEra` old/too-new/duplicate checks).
## 3. Goals
1. Select external validators by weighted stake instead of raw member ordering.
2. Keep selection deterministic (`same chain state -> same selected set`).
3. Preserve PR #433 era-targeting invariants and submitter authorization flow.
4. Enforce total active authority cap = 32 (`whitelisted + external`).
5. Keep payload shape stable unless there is a hard requirement to version it.
## 4. Non-Goals
1. Replacing PR #433 submitter-role model.
2. Changing PR #433 era-target validation semantics.
3. Redesigning Snowbridge transport internals.
4. Changing reward formulas in this spec.
## 5. Current Behavior (Post-PR #433)
### 5.1 Ethereum
`buildNewValidatorSetMessageForEra(targetEra)` gathers all operator-set members with a mapped solochain address and forwards them in that order. There is no stake-based ranking.
### 5.2 Payload
Current payload carries:
1. `validators`
2. `external_index` (interpreted as `targetEra`)
### 5.3 DataHaven Runtime
`set_external_validators_inner()` stores incoming validators and `ExternalIndex`, then era application and validator composition logic consume them.
### 5.4 Limitation
Without stake-aware ordering, high-stake operators may be displaced by lower-stake operators when list size pressure or downstream caps apply.
## 6. Design Decisions
### D1. Do ranking on Ethereum
EigenLayer membership/allocation context is available on Ethereum, so weighted ranking is computed there.
### D2. Keep PR #433 era semantics unchanged
`external_index` must continue to encode `targetEra`. This spec does not repurpose it (no nonce/block-number substitution).
### D3. Deterministic tie-break
For equal weighted stake, lower Ethereum operator address wins.
### D4. Cap applies to total active authorities
Final active validator set must satisfy:
`final_active = take_32(dedupe(whitelisted ++ external_sorted_limited))`
### D5. Strategy multipliers are explicit and default to zero if unset
Multipliers are owner-managed in `strategiesAndMultipliers`. If an entry is unset for a strategy, its effective multiplier is `0` (no weighted contribution).
### D6. Keep strategy list and multipliers in sync
Multiplier lifecycle is tied to strategy lifecycle:
1. Add strategy -> add multiplier in the same call via `IRewardsCoordinatorTypes.StrategyAndMultiplier` struct.
2. Remove strategy -> delete multiplier in the same call.
## 7. Weighted Stake Model
For each operator `o`:
`weightedStake(o) = sum_i( allocatedStake(o, strategy_i) * multiplier(strategy_i) )`
Where:
1. `allocatedStake` comes from EigenLayer allocation data.
2. `multiplier` is a per-strategy weight (no normalization divisor is applied during ranking).
### 7.1 Strategy Weight Semantics
1. Every supported strategy should have an explicit multiplier entry for operational clarity.
2. Missing multiplier entry is treated as `0` multiplier.
3. Multiplier values are managed explicitly by owner/governance.
### 7.2 Unit Assumption
Stake inputs must be unit-consistent across strategies. If they are not, normalize before summing.
## 8. Ethereum Contract Changes (On Top of PR #433)
File: `contracts/src/DataHavenServiceManager.sol`
### 8.1 New State
```solidity
uint32 public constant MAX_ACTIVE_VALIDATORS = 32;
mapping(IStrategy => uint96) public strategiesAndMultipliers;
```
### 8.2 New/Updated Admin APIs
```solidity
function setStrategiesAndMultipliers(IRewardsCoordinatorTypes.StrategyAndMultiplier[] calldata strategyMultipliers) external onlyOwner;
function addStrategiesToValidatorsSupportedStrategies(IRewardsCoordinatorTypes.StrategyAndMultiplier[] calldata strategyMultipliers) external onlyOwner;
function removeStrategiesFromValidatorsSupportedStrategies(IStrategy[] calldata strategies) external onlyOwner;
function getStrategiesAndMultipliers() external view returns (IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory);
```
Using EigenLayer's `StrategyAndMultiplier` struct pairs each strategy with its multiplier, eliminating the possibility of length mismatches between parallel arrays. Duplicate strategies in `addStrategies` are rejected by EigenLayer's `StrategyAlreadyInOperatorSet` check; duplicates in `setStrategiesAndMultipliers` are harmless (last-write-wins on the mapping).
### 8.3 Updated Selection Flow
`buildNewValidatorSetMessageForEra(uint64 targetEra)` should:
1. Read validator operator set members.
2. Compute weighted stake per operator.
3. Filter out operators with no solochain mapping.
4. Resolve multiplier from `strategiesAndMultipliers` for each strategy used.
5. If any strategy is missing a multiplier entry, treat it as `0` multiplier.
6. Filter out operators with zero weighted stake.
7. Select at most `MAX_ACTIVE_VALIDATORS` (32) candidates by weighted stake desc + address asc tie-break (if fewer than 32 eligible candidates exist, include all).
8. Encode using existing payload shape with `externalIndex = targetEra`.
For any EigenLayer call that consumes `StrategyAndMultiplier[]`, materialize the list in ascending strategy-address order.
`sendNewValidatorSetForEra(...)` and `onlyValidatorSetSubmitter` remain unchanged from PR #433.
## 9. Bridge Message Format
No payload version bump in this spec.
Continue using existing `ReceiveValidators` message shape:
```text
[EL_MESSAGE_ID]
[MessageVersion]
[ReceiveValidators]
[validator_count]
[validators (N * 20B)]
[external_index (u64 targetEra)]
```
If stake vectors are required in the future, that should be a separate versioned command proposal.
## 10. DataHaven Runtime Changes
File: `operator/pallets/external-validators/src/lib.rs`
### 10.1 Keep PR #433 era validation
Retain existing target-era gates and error semantics (`TargetEraTooOld`, `TargetEraTooNew`, `DuplicateOrStaleTargetEra`).
### 10.2 Enforce final total cap = 32
At validator composition time:
1. `w = whitelisted.len()`
2. `external_budget = 32.saturating_sub(w)`
3. Use at most `external_budget` external validators from the ranked list.
4. Build final set as `take_32(dedupe(whitelisted ++ external_limited))`.
### 10.3 Runtime constants
`MaxExternalValidators` can remain a defensive bound, but final active enforcement must guarantee max 32 authorities.
## 11. Rollout Plan
1. Merge/deploy PR #433 baseline first (submitter role + era-target checks).
2. Deploy ServiceManager upgrade with weighted ranking logic.
3. Backfill/confirm `strategiesAndMultipliers` for all currently supported strategies.
4. Deploy runtime changes for final total-cap enforcement.
5. Re-run submitter daemon unchanged (it still submits `targetEra = ActiveEra + 1`).
6. Monitor across multiple era cycles before production rollout.
## 12. Testing Plan
### 12.1 Solidity
1. Weighted stake computation across multiple strategies.
2. Deterministic tie-break behavior.
3. Top-32 selection when candidate count exceeds 32.
4. Behavior when candidate count is below 32.
5. Zero-stake filtering.
6. Missing multiplier entries are treated as zero contribution.
7. `addStrategies...` sets multipliers atomically via `StrategyAndMultiplier` struct.
8. `removeStrategies...` removes multiplier entries for removed strategies.
9. `getStrategiesAndMultipliers()` returns a list matching EigenLayer's operator set strategies.
11. Integration with `buildNewValidatorSetMessageForEra(targetEra)` and correct target era encoding.
### 12.2 Runtime
1. Existing PR #433 era-validation tests continue to pass unchanged.
2. Final active authority cap remains <= 32 with mixed whitelisted/external sets.
3. Composition logic preserves whitelisted priority while enforcing cap.
### 12.3 Integration / E2E
1. End-to-end submission through `sendNewValidatorSetForEra` with ranked validator output.
2. Delayed relay still fails with PR #433 semantics (no regressions).
3. Ranked selection outcome is deterministic across repeated runs at fixed state.
## 13. Security Considerations
1. Owner-managed strategy weights are governance-sensitive and should remain multisig/governance controlled.
2. Deterministic ordering prevents non-deterministic set drift.
3. Preserve PR #433 stale/duplicate/too-early rejection invariants.
4. Apply overflow checks in weighted arithmetic and any integer downcasts.
## 14. File Change Summary
1. `contracts/src/DataHavenServiceManager.sol`
- weighted stake computation and deterministic top selection in `buildNewValidatorSetMessageForEra`.
2. `contracts/src/interfaces/IDataHavenServiceManager.sol`
- `strategiesAndMultipliers` naming and add/remove strategy API signature updates with multipliers.
3. `operator/pallets/external-validators/src/lib.rs`
- final authority cap enforcement at composition time (while keeping PR #433 era validation behavior).
4. `contracts/test/*`, `operator/pallets/external-validators/src/tests.rs`, `test/e2e/suites/validator-set-update.test.ts`
- unit/runtime/e2e coverage for weighted selection + strategy/multiplier sync + cap behavior + non-regression on era-targeted flow.