datahaven/specs/validator-set-selection/validator-set-selection.md
Ahmad Kaouk eaf55fb414
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 09:23:57 +01:00

10 KiB

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

uint32 public constant MAX_ACTIVE_VALIDATORS = 32;
mapping(IStrategy => uint96) public strategiesAndMultipliers;

8.2 New/Updated Admin APIs

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:

[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.
  10. 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.