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`
This commit is contained in:
Ahmad Kaouk 2026-02-24 09:23:57 +01:00 committed by GitHub
parent 401f646286
commit eaf55fb414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1575 additions and 659 deletions

View file

@ -21,16 +21,14 @@
"rewardsCoordinatorInitPausedStatus": 0,
"allocationManagerInitPausedStatus": 0,
"deallocationDelay": 50,
"allocationConfigurationDelay": 75,
"allocationConfigurationDelay": 0,
"beaconChainGenesisTimestamp": 1695902400
},
"avs": {
"avsOwner": "0x976EA74026E726554dB657fA54763abd0C3a0aa9",
"rewardsInitiator": "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955",
"validatorSetSubmitter": "0x976EA74026E726554dB657fA54763abd0C3a0aa9",
"validatorsStrategies": [
"0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0"
]
"validatorsStrategies": []
},
"snowbridge": {
"randaoCommitDelay": 4,
@ -49,4 +47,4 @@
"0xf68aec7304bf37f340dae2ea20fb5271ee28a3128812b84a615da4789e458bde"
]
}
}
}

View file

@ -1 +1 @@
aa4e5f7e459c7a4f337016e845cd05aa56cccb41
9c861e3e1d290888127bc6d772fb1a3422bdf8b3

File diff suppressed because one or more lines are too long

View file

@ -32,6 +32,10 @@ import {
} from "eigenlayer-contracts/src/contracts/permissions/PermissionController.sol";
import {EigenPodManager} from "eigenlayer-contracts/src/contracts/pods/EigenPodManager.sol";
import {IETHPOSDeposit} from "eigenlayer-contracts/src/contracts/interfaces/IETHPOSDeposit.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
// DataHaven imports
import {DataHavenServiceManager} from "../../src/DataHavenServiceManager.sol";
@ -41,7 +45,7 @@ import {ValidatorsUtils} from "../../script/utils/ValidatorsUtils.sol";
struct ServiceManagerInitParams {
address avsOwner;
address rewardsInitiator;
address[] validatorsStrategies;
IRewardsCoordinatorTypes.StrategyAndMultiplier[] validatorsStrategiesAndMultipliers;
address gateway;
address validatorSetSubmitter;
}
@ -246,11 +250,21 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
"ServiceManager Implementation", address(serviceManagerImplementation)
);
// Build StrategyAndMultiplier[] from config addresses with default multiplier of 1.
// Multipliers can be updated post-deployment via setStrategiesAndMultipliers if needed.
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory strategiesAndMultipliers = new IRewardsCoordinatorTypes
.StrategyAndMultiplier[](avsConfig.validatorsStrategies.length);
for (uint256 i = 0; i < avsConfig.validatorsStrategies.length; i++) {
strategiesAndMultipliers[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: IStrategy(avsConfig.validatorsStrategies[i]), multiplier: 1
});
}
// Create service manager initialisation parameters struct
ServiceManagerInitParams memory initParams = ServiceManagerInitParams({
avsOwner: avsConfig.avsOwner,
rewardsInitiator: avsConfig.rewardsInitiator,
validatorsStrategies: avsConfig.validatorsStrategies,
validatorsStrategiesAndMultipliers: strategiesAndMultipliers,
gateway: address(gateway),
validatorSetSubmitter: avsConfig.validatorSetSubmitter
});

View file

@ -120,7 +120,7 @@ contract DeployLive is DeployBase {
DataHavenServiceManager.initialize.selector,
params.avsOwner,
params.rewardsInitiator,
params.validatorsStrategies,
params.validatorsStrategiesAndMultipliers,
params.gateway,
params.validatorSetSubmitter
);

View file

@ -206,7 +206,7 @@ contract DeployLocal is DeployBase {
DataHavenServiceManager.initialize.selector,
params.avsOwner,
params.rewardsInitiator,
params.validatorsStrategies,
params.validatorsStrategiesAndMultipliers,
params.gateway,
params.validatorSetSubmitter
);
@ -356,11 +356,15 @@ contract DeployLocal is DeployBase {
function _prepareStrategiesForServiceManager(
ServiceManagerInitParams memory params
) internal view {
if (params.validatorsStrategies.length == 0) {
params.validatorsStrategies = new address[](deployedStrategies.length);
if (params.validatorsStrategiesAndMultipliers.length == 0) {
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
params.validatorsStrategies[i] = deployedStrategies[i].address_;
sm[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: IStrategy(deployedStrategies[i].address_), multiplier: 1
});
}
params.validatorsStrategiesAndMultipliers = sm;
}
}

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.27;
// EigenLayer imports
import {
IAllocationManagerTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol";
import {OperatorSet} from "eigenlayer-contracts/src/contracts/libraries/OperatorSetLib.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
// Testing imports
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
import {Logging} from "../utils/Logging.sol";
import {ELScriptStorage} from "../utils/ELScriptStorage.s.sol";
import {DHScriptStorage} from "../utils/DHScriptStorage.s.sol";
import {Accounts} from "../utils/Accounts.sol";
/**
* @title AllocateOperatorStake
* @notice Allocates full magnitude to the validator operator set.
* Must be run AFTER SignUpValidator (needs at least 1 block gap
* for the allocation delay to initialize).
*/
contract AllocateOperatorStake is Script, ELScriptStorage, DHScriptStorage, Accounts {
function run() public {
string memory network = vm.envOr("NETWORK", string("anvil"));
Logging.logHeader("ALLOCATE OPERATOR STAKE");
console.log("| Network: %s", network);
Logging.logFooter();
_loadELContracts(network);
_loadDHContracts(network);
IStrategy[] memory strategies = new IStrategy[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
strategies[i] = IStrategy(address(deployedStrategies[i].strategy));
}
uint64[] memory newMagnitudes = new uint64[](strategies.length);
for (uint256 i = 0; i < strategies.length; i++) {
newMagnitudes[i] = 1e18;
}
IAllocationManagerTypes.AllocateParams[] memory allocParams =
new IAllocationManagerTypes.AllocateParams[](1);
allocParams[0] = IAllocationManagerTypes.AllocateParams({
operatorSet: OperatorSet({
avs: address(serviceManager), id: serviceManager.VALIDATORS_SET_ID()
}),
strategies: strategies,
newMagnitudes: newMagnitudes
});
vm.broadcast(_operatorPrivateKey);
allocationManager.modifyAllocations(_operator, allocParams);
Logging.logStep(
string.concat("Allocated full magnitude for operator: ", vm.toString(_operator))
);
}
}

View file

@ -33,8 +33,8 @@ normalize_json() {
| .storage
| map(
del(.astId, .contract)
# Remove unstable AST ID suffixes from type strings (e.g., t_contract(IGatewayV2)12345)
| .type |= sub("\\)[0-9]+$"; ")")
# Remove unstable AST IDs from type strings (e.g., t_contract(IGatewayV2)12345, nested mappings)
| .type |= gsub("\\)[0-9]+"; ")")
)
| sort_by(.slot | tonumber)' "$1"
}

View file

@ -42,6 +42,9 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
/// @notice The EigenLayer operator set ID for the Validators securing the DataHaven network.
uint32 public constant VALIDATORS_SET_ID = 0;
/// @notice Maximum number of active validators in the set
uint32 public constant MAX_ACTIVE_VALIDATORS = 32;
// ============ Immutables ============
/// @notice The EigenLayer AllocationManager contract
@ -64,15 +67,17 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
/// @inheritdoc IDataHavenServiceManager
mapping(address => address) public validatorEthAddressToSolochainAddress;
/// @inheritdoc IDataHavenServiceManager
mapping(address => address) public validatorSolochainAddressToEthAddress;
/// @inheritdoc IDataHavenServiceManager
address public validatorSetSubmitter;
/// @inheritdoc IDataHavenServiceManager
mapping(IStrategy => uint96) public strategiesAndMultipliers;
/// @notice Storage gap for upgradeability (must be at end of state variables)
// solhint-disable-next-line var-name-mixedcase
uint256[44] private __GAP;
uint256[43] private __GAP;
// ============ Modifiers ============
@ -138,7 +143,7 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
function initialize(
address initialOwner,
address _rewardsInitiator,
IStrategy[] memory validatorsStrategies,
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory validatorsStrategiesAndMultipliers,
address _snowbridgeGatewayAddress,
address _validatorSetSubmitter
) public virtual initializer {
@ -154,11 +159,20 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
// Register the DataHaven service in the AllocationManager.
_ALLOCATION_MANAGER.updateAVSMetadataURI(address(this), DATAHAVEN_AVS_METADATA);
// Build the strategies array and populate multipliers atomically so that
// getStrategiesInOperatorSet and strategiesAndMultipliers are always consistent.
IStrategy[] memory strategies = new IStrategy[](validatorsStrategiesAndMultipliers.length);
for (uint256 i = 0; i < validatorsStrategiesAndMultipliers.length; i++) {
strategies[i] = validatorsStrategiesAndMultipliers[i].strategy;
strategiesAndMultipliers[validatorsStrategiesAndMultipliers[i].strategy] =
validatorsStrategiesAndMultipliers[i].multiplier;
}
// Create the operator set for the DataHaven service.
IAllocationManagerTypes.CreateSetParams[] memory operatorSets =
new IAllocationManagerTypes.CreateSetParams[](1);
operatorSets[0] = IAllocationManagerTypes.CreateSetParams({
operatorSetId: VALIDATORS_SET_ID, strategies: validatorsStrategies
operatorSetId: VALIDATORS_SET_ID, strategies: strategies
});
_ALLOCATION_MANAGER.createOperatorSets(address(this), operatorSets);
@ -200,22 +214,72 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
uint64 targetEra
) public view returns (bytes memory) {
OperatorSet memory operatorSet = OperatorSet({avs: address(this), id: VALIDATORS_SET_ID});
address[] memory currentValidatorSet = _ALLOCATION_MANAGER.getMembers(operatorSet);
address[] memory operators = _ALLOCATION_MANAGER.getMembers(operatorSet);
IStrategy[] memory strategies = _ALLOCATION_MANAGER.getStrategiesInOperatorSet(operatorSet);
// Allocate max size, then resize after filtering
address[] memory newValidatorSet = new address[](currentValidatorSet.length);
uint256 validCount = 0;
for (uint256 i = 0; i < currentValidatorSet.length; i++) {
address solochainAddr = validatorEthAddressToSolochainAddress[currentValidatorSet[i]];
if (solochainAddr != address(0)) {
newValidatorSet[validCount] = solochainAddr;
++validCount;
// Get allocated stake for all operators across all strategies
uint256[][] memory allocatedStake =
_ALLOCATION_MANAGER.getAllocatedStake(operatorSet, operators, strategies);
// Collect candidates: operators with solochain mapping and non-zero weighted stake
address[] memory candidateSolochain = new address[](operators.length);
uint256[] memory candidateStake = new uint256[](operators.length);
address[] memory candidateOperator = new address[](operators.length);
uint256 candidateCount = 0;
for (uint256 i = 0; i < operators.length; i++) {
address solochainAddr = validatorEthAddressToSolochainAddress[operators[i]];
if (solochainAddr == address(0)) continue;
// Compute weighted stake across all strategies:
// weightedStake = sum(allocatedStake[i][j] * multiplier[j])
uint256 weightedStake = 0;
for (uint256 j = 0; j < strategies.length; j++) {
weightedStake += allocatedStake[i][j]
* uint256(strategiesAndMultipliers[strategies[j]]);
}
if (weightedStake == 0) continue;
candidateSolochain[candidateCount] = solochainAddr;
candidateStake[candidateCount] = weightedStake;
candidateOperator[candidateCount] = operators[i];
candidateCount++;
}
require(candidateCount != 0, EmptyValidatorSet());
// Partial selection sort: pick top min(MAX_ACTIVE_VALIDATORS, candidateCount)
uint256 selectCount =
candidateCount < MAX_ACTIVE_VALIDATORS ? candidateCount : MAX_ACTIVE_VALIDATORS;
for (uint256 i = 0; i < selectCount; i++) {
uint256 bestIdx = i;
for (uint256 j = i + 1; j < candidateCount; j++) {
if (_isBetterCandidate(
candidateStake[j],
candidateOperator[j],
candidateStake[bestIdx],
candidateOperator[bestIdx]
)) {
bestIdx = j;
}
}
if (bestIdx != i) {
// Swap all parallel arrays
(candidateSolochain[i], candidateSolochain[bestIdx]) =
(candidateSolochain[bestIdx], candidateSolochain[i]);
(candidateStake[i], candidateStake[bestIdx]) =
(candidateStake[bestIdx], candidateStake[i]);
(candidateOperator[i], candidateOperator[bestIdx]) =
(candidateOperator[bestIdx], candidateOperator[i]);
}
}
require(validCount != 0, EmptyValidatorSet());
// Resize array to actual count
assembly {
mstore(newValidatorSet, validCount)
// Build the final validator set from sorted solochain addresses
address[] memory newValidatorSet = new address[](selectCount);
for (uint256 i = 0; i < selectCount; i++) {
newValidatorSet[i] = candidateSolochain[i];
}
return DataHavenSnowbridgeMessages.scaleEncodeNewValidatorSetMessagePayload(
@ -351,15 +415,71 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
_ALLOCATION_MANAGER.removeStrategiesFromOperatorSet(
address(this), VALIDATORS_SET_ID, _strategies
);
for (uint256 i = 0; i < _strategies.length; i++) {
delete strategiesAndMultipliers[_strategies[i]];
}
}
/// @inheritdoc IDataHavenServiceManager
function addStrategiesToValidatorsSupportedStrategies(
IStrategy[] calldata _strategies
IRewardsCoordinatorTypes.StrategyAndMultiplier[] calldata _strategyMultipliers
) external onlyOwner {
_ALLOCATION_MANAGER.addStrategiesToOperatorSet(
address(this), VALIDATORS_SET_ID, _strategies
);
IStrategy[] memory strategies = new IStrategy[](_strategyMultipliers.length);
for (uint256 i = 0; i < _strategyMultipliers.length; i++) {
strategies[i] = _strategyMultipliers[i].strategy;
strategiesAndMultipliers[_strategyMultipliers[i].strategy] =
_strategyMultipliers[i].multiplier;
}
_ALLOCATION_MANAGER.addStrategiesToOperatorSet(address(this), VALIDATORS_SET_ID, strategies);
emit StrategiesAndMultipliersSet(_strategyMultipliers);
}
/// @inheritdoc IDataHavenServiceManager
function setStrategiesAndMultipliers(
IRewardsCoordinatorTypes.StrategyAndMultiplier[] calldata _strategyMultipliers
) external onlyOwner {
OperatorSet memory operatorSet = OperatorSet({avs: address(this), id: VALIDATORS_SET_ID});
IStrategy[] memory registered = _ALLOCATION_MANAGER.getStrategiesInOperatorSet(operatorSet);
for (uint256 i = 0; i < _strategyMultipliers.length; i++) {
bool found = false;
for (uint256 j = 0; j < registered.length; j++) {
if (registered[j] == _strategyMultipliers[i].strategy) {
found = true;
break;
}
}
require(found, StrategyNotInOperatorSet());
strategiesAndMultipliers[_strategyMultipliers[i].strategy] =
_strategyMultipliers[i].multiplier;
}
emit StrategiesAndMultipliersSet(_strategyMultipliers);
}
/// @inheritdoc IDataHavenServiceManager
function getStrategiesAndMultipliers()
external
view
returns (IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory)
{
OperatorSet memory operatorSet = OperatorSet({avs: address(this), id: VALIDATORS_SET_ID});
IStrategy[] memory strategies = _ALLOCATION_MANAGER.getStrategiesInOperatorSet(operatorSet);
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory result =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](strategies.length);
for (uint256 i = 0; i < strategies.length; i++) {
result[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[i], multiplier: strategiesAndMultipliers[strategies[i]]
});
}
return result;
}
// ============ Rewards Functions ============
@ -487,6 +607,27 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
require(result != address(0), ZeroAddress());
}
/**
* @notice Determines if candidate A ranks higher than candidate B
* @dev Higher stake wins; on tie, lower operator address wins
* @param stakeA Weighted stake of candidate A
* @param opA Operator address of candidate A
* @param stakeB Weighted stake of candidate B
* @param opB Operator address of candidate B
* @return True if candidate A ranks higher than candidate B
*/
function _isBetterCandidate(
uint256 stakeA,
address opA,
uint256 stakeB,
address opB
) private pure returns (bool) {
if (stakeA != stakeB) {
return stakeA > stakeB;
}
return opA < opB;
}
/**
* @notice Returns the EigenLayer operator address for a Solochain validator address
* @dev Reverts if the Solochain address has not been mapped to an operator

View file

@ -42,6 +42,9 @@ interface IDataHavenServiceManagerErrors {
/// @notice Thrown when a Solochain address is already assigned to a different operator
error SolochainAddressAlreadyAssigned();
/// @notice Thrown when a strategy is not registered in the operator set
error StrategyNotInOperatorSet();
}
/**
@ -89,6 +92,10 @@ interface IDataHavenServiceManagerEvents {
/// @notice Emitted when a batch of slashing request is being successfully slashed
event SlashingComplete();
/// @notice Emitted when strategy multipliers are set or updated
/// @param strategyMultipliers Array of strategy-multiplier pairs that were set
event StrategiesAndMultipliersSet(IRewardsCoordinatorTypes
.StrategyAndMultiplier[] strategyMultipliers);
/// @notice Emitted when the validator set submitter address is updated
/// @param oldSubmitter The previous validator set submitter address
/// @param newSubmitter The new validator set submitter address
@ -166,12 +173,15 @@ interface IDataHavenServiceManager is
* @notice Initializes the DataHaven Service Manager
* @param initialOwner Address of the initial owner
* @param rewardsInitiator Address authorized to initiate rewards
* @param validatorsStrategies Array of strategies supported by validators
* @param validatorsStrategiesAndMultipliers Array of strategy-multiplier pairs for the validators
* operator set. Each multiplier must be non-zero.
* @param _snowbridgeGatewayAddress Address of the Snowbridge Gateway
* @param _validatorSetSubmitter Address authorized to submit validator set messages
*/
function initialize(
address initialOwner,
address rewardsInitiator,
IStrategy[] memory validatorsStrategies,
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory validatorsStrategiesAndMultipliers,
address _snowbridgeGatewayAddress,
address _validatorSetSubmitter
) external;
@ -193,9 +203,13 @@ interface IDataHavenServiceManager is
) external payable;
/**
* @notice Builds a new validator set message for a target era
* @notice Builds a SCALE-encoded message containing the top validators by weighted stake
* @dev Selects up to MAX_ACTIVE_VALIDATORS from registered operators. Each operator's
* weighted stake is computed as: sum(allocatedStake[j] * multiplier[j])
* across all strategies. Operators without a solochain address mapping or with zero
* weighted stake are excluded. Ties are broken by lower operator address.
* @param targetEra The target era to encode in the message
* @return The encoded message bytes to be sent to the Snowbridge Gateway
* @return The SCALE-encoded message bytes to be sent to the Snowbridge Gateway
*/
function buildNewValidatorSetMessageForEra(
uint64 targetEra
@ -251,12 +265,50 @@ interface IDataHavenServiceManager is
/**
* @notice Adds strategies to the list of supported strategies for DataHaven Validators
* @param _strategies Array of strategy contracts to add to validators operator set
* @dev Each strategy's multiplier determines its weight in the validator selection
* formula: weightedStake = sum(allocatedStake[j] * multiplier[j])
* @param _strategyMultipliers Array of strategy-multiplier pairs to add
*/
function addStrategiesToValidatorsSupportedStrategies(
IStrategy[] calldata _strategies
IRewardsCoordinatorTypes.StrategyAndMultiplier[] calldata _strategyMultipliers
) external;
/**
* @notice Returns the maximum number of active validators in the set
* @return The maximum active validators constant
*/
function MAX_ACTIVE_VALIDATORS() external pure returns (uint32);
/**
* @notice Returns the multiplier for a given strategy
* @dev The multiplier determines how much an operator's allocated stake in this strategy
* contributes to their weighted stake during validator set selection.
* @param strategy The strategy to look up
* @return The multiplier weight
*/
function strategiesAndMultipliers(
IStrategy strategy
) external view returns (uint96);
/**
* @notice Updates multipliers for strategies already in the operator set
* @dev Does not add or remove strategies from EigenLayer; only updates multiplier weights
* used in the validator selection weighted stake formula
* @param _strategyMultipliers Array of strategy-multiplier pairs to update
*/
function setStrategiesAndMultipliers(
IRewardsCoordinatorTypes.StrategyAndMultiplier[] calldata _strategyMultipliers
) external;
/**
* @notice Returns all strategies with their multipliers
* @return Array of StrategyAndMultiplier structs with strategy addresses and multiplier weights
*/
function getStrategiesAndMultipliers()
external
view
returns (IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory);
// ============ Rewards Submitter Functions ============
/**

View file

@ -41,7 +41,7 @@
"type": "t_array(t_uint256)49_storage"
},
{
"astId": 23771,
"astId": 23775,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "rewardsInitiator",
"offset": 0,
@ -49,7 +49,7 @@
"type": "t_address"
},
{
"astId": 23776,
"astId": 23780,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorsAllowlist",
"offset": 0,
@ -57,7 +57,7 @@
"type": "t_mapping(t_address,t_bool)"
},
{
"astId": 23780,
"astId": 23784,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "_snowbridgeGateway",
"offset": 0,
@ -65,7 +65,7 @@
"type": "t_contract(IGatewayV2)23481"
},
{
"astId": 23785,
"astId": 23789,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorEthAddressToSolochainAddress",
"offset": 0,
@ -73,7 +73,7 @@
"type": "t_mapping(t_address,t_address)"
},
{
"astId": 23790,
"astId": 23793,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorSolochainAddressToEthAddress",
"offset": 0,
@ -81,7 +81,7 @@
"type": "t_mapping(t_address,t_address)"
},
{
"astId": 23793,
"astId": 23796,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorSetSubmitter",
"offset": 0,
@ -89,12 +89,20 @@
"type": "t_address"
},
{
"astId": 23798,
"astId": 23802,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "strategiesAndMultipliers",
"offset": 0,
"slot": "107",
"type": "t_mapping(t_contract(IStrategy)7361,t_uint96)"
},
{
"astId": 23807,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "__GAP",
"offset": 0,
"slot": "107",
"type": "t_array(t_uint256)44_storage"
"slot": "108",
"type": "t_array(t_uint256)43_storage"
}
],
"types": {
@ -103,10 +111,10 @@
"label": "address",
"numberOfBytes": "20"
},
"t_array(t_uint256)44_storage": {
"t_array(t_uint256)43_storage": {
"encoding": "inplace",
"label": "uint256[44]",
"numberOfBytes": "1408",
"label": "uint256[43]",
"numberOfBytes": "1376",
"base": "t_uint256"
},
"t_array(t_uint256)49_storage": {
@ -131,6 +139,11 @@
"label": "contract IGatewayV2",
"numberOfBytes": "20"
},
"t_contract(IStrategy)7361": {
"encoding": "inplace",
"label": "contract IStrategy",
"numberOfBytes": "20"
},
"t_mapping(t_address,t_address)": {
"encoding": "mapping",
"key": "t_address",
@ -145,6 +158,13 @@
"numberOfBytes": "32",
"value": "t_bool"
},
"t_mapping(t_contract(IStrategy)7361,t_uint96)": {
"encoding": "mapping",
"key": "t_contract(IStrategy)7361",
"label": "mapping(contract IStrategy => uint96)",
"numberOfBytes": "32",
"value": "t_uint96"
},
"t_uint256": {
"encoding": "inplace",
"label": "uint256",
@ -154,6 +174,11 @@
"encoding": "inplace",
"label": "uint8",
"numberOfBytes": "1"
},
"t_uint96": {
"encoding": "inplace",
"label": "uint96",
"numberOfBytes": "12"
}
}
}

View file

@ -24,7 +24,8 @@ contract SnowbridgeIntegrationTest is SnowbridgeAndAVSDeployer {
) public pure returns (bytes[] memory beforeTestCalldata) {
if (testSelector == this.test_sendNewValidatorsSetMessage.selector) {
beforeTestCalldata = new bytes[](1);
beforeTestCalldata[0] = abi.encodeWithSelector(this.setupValidatorsAsOperators.selector);
beforeTestCalldata[0] =
abi.encodeWithSelector(this.setupValidatorsAsOperatorsWithAllocations.selector);
}
}

View file

@ -0,0 +1,568 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.27;
/* solhint-disable func-name-mixedcase */
import {SnowbridgeAndAVSDeployer} from "./utils/SnowbridgeAndAVSDeployer.sol";
import {DataHavenSnowbridgeMessages} from "../src/libraries/DataHavenSnowbridgeMessages.sol";
import {IDataHavenServiceManagerErrors} from "../src/interfaces/IDataHavenServiceManager.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
import {
IAllocationManagerTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol";
import {OperatorSet} from "eigenlayer-contracts/src/contracts/libraries/OperatorSetLib.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ValidatorSetSelectionTest is SnowbridgeAndAVSDeployer {
function setUp() public {
_deployMockAllContracts();
}
// ============ Helpers ============
function _getStrategies() internal view returns (IStrategy[] memory) {
IStrategy[] memory strategies = new IStrategy[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
strategies[i] = deployedStrategies[i];
}
return strategies;
}
function _setupMultipliers(
uint96[] memory multipliers
) internal {
IStrategy[] memory strategies = _getStrategies();
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](strategies.length);
for (uint256 i = 0; i < strategies.length; i++) {
sm[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[i], multiplier: multipliers[i]
});
}
cheats.startPrank(avsOwner);
serviceManager.removeStrategiesFromValidatorsSupportedStrategies(strategies);
serviceManager.addStrategiesToValidatorsSupportedStrategies(sm);
cheats.stopPrank();
}
function _uniformMultipliers() internal pure returns (uint96[] memory) {
uint96[] memory m = new uint96[](3);
m[0] = 1;
m[1] = 1;
m[2] = 1;
return m;
}
function _registerOperator(
address op,
address solochainAddr,
uint256[] memory stakeAmounts
) internal {
cheats.prank(avsOwner);
serviceManager.addValidatorToAllowlist(op);
cheats.startPrank(op);
for (uint256 j = 0; j < deployedStrategies.length; j++) {
IERC20 linkedToken = deployedStrategies[j].underlyingToken();
_setERC20Balance(address(linkedToken), op, stakeAmounts[j]);
linkedToken.approve(address(strategyManager), stakeAmounts[j]);
strategyManager.depositIntoStrategy(deployedStrategies[j], linkedToken, stakeAmounts[j]);
}
delegationManager.registerAsOperator(address(0), 0, "");
uint32[] memory operatorSetIds = new uint32[](1);
operatorSetIds[0] = serviceManager.VALIDATORS_SET_ID();
IAllocationManagerTypes.RegisterParams memory registerParams =
IAllocationManagerTypes.RegisterParams({
avs: address(serviceManager),
operatorSetIds: operatorSetIds,
data: abi.encodePacked(solochainAddr)
});
allocationManager.registerForOperatorSets(op, registerParams);
cheats.stopPrank();
}
function _uniformStakes(
uint256 amount
) internal view returns (uint256[] memory) {
uint256[] memory stakes = new uint256[](deployedStrategies.length);
for (uint256 j = 0; j < stakes.length; j++) {
stakes[j] = amount;
}
return stakes;
}
function _allocateForOperator(
address op
) internal {
IStrategy[] memory strategies = _getStrategies();
uint64[] memory newMagnitudes = new uint64[](strategies.length);
for (uint256 j = 0; j < strategies.length; j++) {
newMagnitudes[j] = 1e18;
}
IAllocationManagerTypes.AllocateParams[] memory allocParams =
new IAllocationManagerTypes.AllocateParams[](1);
allocParams[0] = IAllocationManagerTypes.AllocateParams({
operatorSet: OperatorSet({
avs: address(serviceManager), id: serviceManager.VALIDATORS_SET_ID()
}),
strategies: strategies,
newMagnitudes: newMagnitudes
});
cheats.prank(op);
allocationManager.modifyAllocations(op, allocParams);
}
function _advancePastAllocationConfigDelay() internal {
uint32 delay = allocationManager.ALLOCATION_CONFIGURATION_DELAY();
cheats.roll(block.number + delay + 1);
}
function _advancePastAllocationEffect() internal {
cheats.roll(block.number + 1);
}
function _buildExpectedMessage(
address[] memory validators,
uint64 externalIndex
) internal pure returns (bytes memory) {
return DataHavenSnowbridgeMessages.scaleEncodeNewValidatorSetMessagePayload(
DataHavenSnowbridgeMessages.NewValidatorSetPayload({
validators: validators, externalIndex: externalIndex
})
);
}
// ============ Admin Function Tests ============
// Test #7: Add strategy + multiplier in one call; verify both stored
function test_addStrategies_setsMultiplierAtomically() public {
IStrategy[] memory strategies = _getStrategies();
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](3);
sm[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[0], multiplier: 5000
});
sm[1] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[1], multiplier: 10000
});
sm[2] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[2], multiplier: 2000
});
cheats.startPrank(avsOwner);
serviceManager.removeStrategiesFromValidatorsSupportedStrategies(strategies);
serviceManager.addStrategiesToValidatorsSupportedStrategies(sm);
cheats.stopPrank();
assertEq(serviceManager.strategiesAndMultipliers(strategies[0]), 5000);
assertEq(serviceManager.strategiesAndMultipliers(strategies[1]), 10000);
assertEq(serviceManager.strategiesAndMultipliers(strategies[2]), 2000);
}
// Test #9: Remove strategy multiplier and tracking bool deleted
function test_removeStrategies_cleansUpMultiplier() public {
IStrategy[] memory strategies = _getStrategies();
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](3);
sm[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[0], multiplier: 5000
});
sm[1] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[1], multiplier: 10000
});
sm[2] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[2], multiplier: 2000
});
cheats.startPrank(avsOwner);
serviceManager.removeStrategiesFromValidatorsSupportedStrategies(strategies);
serviceManager.addStrategiesToValidatorsSupportedStrategies(sm);
assertEq(serviceManager.strategiesAndMultipliers(strategies[1]), 10000);
serviceManager.removeStrategiesFromValidatorsSupportedStrategies(strategies);
cheats.stopPrank();
assertEq(serviceManager.strategiesAndMultipliers(strategies[0]), 0);
assertEq(serviceManager.strategiesAndMultipliers(strategies[1]), 0);
assertEq(serviceManager.strategiesAndMultipliers(strategies[2]), 0);
}
// Test #11: Returns correct StrategyAndMultiplier structs
function test_getStrategiesAndMultipliers_returnsCorrect() public {
IStrategy[] memory strategies = _getStrategies();
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](3);
sm[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[0], multiplier: 5000
});
sm[1] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[1], multiplier: 10000
});
sm[2] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[2], multiplier: 2000
});
cheats.startPrank(avsOwner);
serviceManager.removeStrategiesFromValidatorsSupportedStrategies(strategies);
serviceManager.addStrategiesToValidatorsSupportedStrategies(sm);
cheats.stopPrank();
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory result =
serviceManager.getStrategiesAndMultipliers();
assertEq(result.length, 3);
for (uint256 i = 0; i < result.length; i++) {
uint96 expectedMultiplier = serviceManager.strategiesAndMultipliers(result[i].strategy);
assertEq(result[i].multiplier, expectedMultiplier);
}
}
// Test: setStrategiesAndMultipliers updates existing multipliers
function test_setStrategiesAndMultipliers_updatesMultipliers() public {
IStrategy[] memory strategies = _getStrategies();
// Set initial multipliers via _setupMultipliers
uint96[] memory initial = new uint96[](3);
initial[0] = 5000;
initial[1] = 10000;
initial[2] = 2000;
_setupMultipliers(initial);
// Update multipliers
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory updated =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](3);
updated[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[0], multiplier: 1
});
updated[1] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[1], multiplier: 1
});
updated[2] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[2], multiplier: 9999
});
cheats.prank(avsOwner);
serviceManager.setStrategiesAndMultipliers(updated);
assertEq(serviceManager.strategiesAndMultipliers(strategies[0]), 1);
assertEq(serviceManager.strategiesAndMultipliers(strategies[1]), 1);
assertEq(serviceManager.strategiesAndMultipliers(strategies[2]), 9999);
}
// Test: setStrategiesAndMultipliers changes validator ranking
function test_setStrategiesAndMultipliers_affectsRanking() public {
uint96[] memory mults = new uint96[](3);
mults[0] = 10000;
mults[1] = 1;
mults[2] = 1;
_setupMultipliers(mults);
// Op A: heavy in strategy 0 (high multiplier) initially ranked first
address opA = vm.addr(801);
address solochainA = address(uint160(0x6001));
uint256[] memory stakesA = new uint256[](3);
stakesA[0] = 1000 ether;
stakesA[1] = 10 ether;
stakesA[2] = 10 ether;
_registerOperator(opA, solochainA, stakesA);
// Op B: heavy in strategy 1 (low multiplier) initially ranked second
address opB = vm.addr(802);
address solochainB = address(uint160(0x6002));
uint256[] memory stakesB = new uint256[](3);
stakesB[0] = 10 ether;
stakesB[1] = 1000 ether;
stakesB[2] = 10 ether;
_registerOperator(opB, solochainB, stakesB);
_advancePastAllocationConfigDelay();
_allocateForOperator(opA);
_allocateForOperator(opB);
_advancePastAllocationEffect();
// Before update: A ranks first (strategy 0 has multiplier 10_000)
address[] memory expectedBefore = new address[](2);
expectedBefore[0] = solochainA;
expectedBefore[1] = solochainB;
assertEq(
serviceManager.buildNewValidatorSetMessageForEra(0),
_buildExpectedMessage(expectedBefore, 0)
);
// Flip multipliers: strategy 1 now has high multiplier
IStrategy[] memory strategies = _getStrategies();
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory flipped =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](3);
flipped[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[0], multiplier: 1
});
flipped[1] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[1], multiplier: 10000
});
flipped[2] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[2], multiplier: 1
});
cheats.prank(avsOwner);
serviceManager.setStrategiesAndMultipliers(flipped);
// After update: B ranks first (strategy 1 now has multiplier 10_000)
address[] memory expectedAfter = new address[](2);
expectedAfter[0] = solochainB;
expectedAfter[1] = solochainA;
assertEq(
serviceManager.buildNewValidatorSetMessageForEra(0),
_buildExpectedMessage(expectedAfter, 0)
);
}
// ============ Selection Tests ============
// Test #1: 3 strategies with different multipliers; verify correct ordering
function test_weightedStake_multipleStrategies() public {
uint96[] memory mults = new uint96[](3);
mults[0] = 5000;
mults[1] = 10000;
mults[2] = 2000;
_setupMultipliers(mults);
// Op A: heavy in strategy 0 (multiplier 5000)
address opA = vm.addr(101);
address solochainA = address(uint160(0xA01));
uint256[] memory stakesA = new uint256[](3);
stakesA[0] = 1000 ether;
stakesA[1] = 100 ether;
stakesA[2] = 100 ether;
_registerOperator(opA, solochainA, stakesA);
// Op B: heavy in strategy 1 (multiplier 10000) highest weighted stake
address opB = vm.addr(102);
address solochainB = address(uint160(0xB01));
uint256[] memory stakesB = new uint256[](3);
stakesB[0] = 100 ether;
stakesB[1] = 1000 ether;
stakesB[2] = 100 ether;
_registerOperator(opB, solochainB, stakesB);
// Op C: heavy in strategy 2 (multiplier 2000) lowest weighted stake
address opC = vm.addr(103);
address solochainC = address(uint160(0xC01));
uint256[] memory stakesC = new uint256[](3);
stakesC[0] = 100 ether;
stakesC[1] = 100 ether;
stakesC[2] = 1000 ether;
_registerOperator(opC, solochainC, stakesC);
_advancePastAllocationConfigDelay();
_allocateForOperator(opA);
_allocateForOperator(opB);
_allocateForOperator(opC);
_advancePastAllocationEffect();
// Expected order: B (highest multiplied strategy), A, C
address[] memory expected = new address[](3);
expected[0] = solochainB;
expected[1] = solochainA;
expected[2] = solochainC;
assertEq(
serviceManager.buildNewValidatorSetMessageForEra(0), _buildExpectedMessage(expected, 0)
);
}
// Test #2: 2 operators with identical weighted stake; lower Eth address ranks first
function test_tieBreak_lowerAddressWins() public {
_setupMultipliers(_uniformMultipliers());
address addrA = vm.addr(201);
address addrB = vm.addr(202);
// Ensure addrLow < addrHigh
address addrLow = addrA < addrB ? addrA : addrB;
address addrHigh = addrA < addrB ? addrB : addrA;
address solochainLow = address(uint160(0xBB));
address solochainHigh = address(uint160(0xAA));
_registerOperator(addrLow, solochainLow, _uniformStakes(500 ether));
_registerOperator(addrHigh, solochainHigh, _uniformStakes(500 ether));
_advancePastAllocationConfigDelay();
_allocateForOperator(addrLow);
_allocateForOperator(addrHigh);
_advancePastAllocationEffect();
// Lower Eth address wins tie-break
address[] memory expected = new address[](2);
expected[0] = solochainLow;
expected[1] = solochainHigh;
assertEq(
serviceManager.buildNewValidatorSetMessageForEra(0), _buildExpectedMessage(expected, 0)
);
}
// Test #3: Register 35 operators; verify only top 32 selected
function test_topN_moreThan32() public {
_setupMultipliers(_uniformMultipliers());
uint256 totalOps = 35;
address[] memory operators = new address[](totalOps);
address[] memory solochainAddrs = new address[](totalOps);
for (uint256 i = 0; i < totalOps; i++) {
operators[i] = vm.addr(300 + i);
solochainAddrs[i] = address(uint160(0x1000 + i));
_registerOperator(operators[i], solochainAddrs[i], _uniformStakes((i + 1) * 10 ether));
}
_advancePastAllocationConfigDelay();
for (uint256 i = 0; i < totalOps; i++) {
_allocateForOperator(operators[i]);
}
_advancePastAllocationEffect();
bytes memory message = serviceManager.buildNewValidatorSetMessageForEra(0);
// Top 32 by descending stake: operators at indices 34, 33, ..., 3
address[] memory expected = new address[](32);
for (uint256 i = 0; i < 32; i++) {
expected[i] = solochainAddrs[totalOps - 1 - i];
}
assertEq(message, _buildExpectedMessage(expected, 0));
}
// Test #4: 5 operators; all included in output
function test_lessThan32_includesAll() public {
_setupMultipliers(_uniformMultipliers());
uint256 totalOps = 5;
address[] memory operators = new address[](totalOps);
address[] memory solochainAddrs = new address[](totalOps);
for (uint256 i = 0; i < totalOps; i++) {
operators[i] = vm.addr(400 + i);
solochainAddrs[i] = address(uint160(0x2000 + i));
_registerOperator(operators[i], solochainAddrs[i], _uniformStakes((i + 1) * 100 ether));
}
_advancePastAllocationConfigDelay();
for (uint256 i = 0; i < totalOps; i++) {
_allocateForOperator(operators[i]);
}
_advancePastAllocationEffect();
// All 5 included, sorted by descending stake
address[] memory expected = new address[](5);
for (uint256 i = 0; i < 5; i++) {
expected[i] = solochainAddrs[totalOps - 1 - i];
}
assertEq(
serviceManager.buildNewValidatorSetMessageForEra(0), _buildExpectedMessage(expected, 0)
);
}
// Test #5: Operator with zero allocation excluded
function test_zeroWeightedStake_filtered() public {
_setupMultipliers(_uniformMultipliers());
address op1 = vm.addr(501);
address solochain1 = address(uint160(0x3001));
_registerOperator(op1, solochain1, _uniformStakes(100 ether));
address op2 = vm.addr(502);
address solochain2 = address(uint160(0x3002));
_registerOperator(op2, solochain2, _uniformStakes(200 ether));
// op3 registered but NOT allocated zero weighted stake
address op3 = vm.addr(503);
address solochain3 = address(uint160(0x3003));
_registerOperator(op3, solochain3, _uniformStakes(300 ether));
_advancePastAllocationConfigDelay();
// Only allocate for op1 and op2
_allocateForOperator(op1);
_allocateForOperator(op2);
_advancePastAllocationEffect();
// op3 should be filtered out
address[] memory expected = new address[](2);
expected[0] = solochain2;
expected[1] = solochain1;
assertEq(
serviceManager.buildNewValidatorSetMessageForEra(0), _buildExpectedMessage(expected, 0)
);
}
// Test #6: A zero multiplier is accepted and causes that strategy's stake to contribute
// no weight. The operator is still included if other strategies have non-zero multipliers.
function test_zeroMultiplier_accepted_contributesNoWeight() public {
IStrategy[] memory strategies = _getStrategies();
// Zero-out the first strategy's multiplier via setStrategiesAndMultipliers
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](1);
sm[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[0], multiplier: 0
});
cheats.prank(avsOwner);
serviceManager.setStrategiesAndMultipliers(sm);
assertEq(serviceManager.strategiesAndMultipliers(strategies[0]), 0);
}
// Test #12: Full integration weighted selection + correct message encoding
function test_buildMessage_encodesCorrectly() public {
_setupMultipliers(_uniformMultipliers());
address op1 = vm.addr(701);
address solochain1 = address(uint160(0x5001));
_registerOperator(op1, solochain1, _uniformStakes(500 ether));
address op2 = vm.addr(702);
address solochain2 = address(uint160(0x5002));
_registerOperator(op2, solochain2, _uniformStakes(1000 ether));
_advancePastAllocationConfigDelay();
_allocateForOperator(op1);
_allocateForOperator(op2);
_advancePastAllocationEffect();
bytes memory message = serviceManager.buildNewValidatorSetMessageForEra(0);
// op2 has higher stake first
address[] memory expected = new address[](2);
expected[0] = solochain2;
expected[1] = solochain1;
assertEq(message, _buildExpectedMessage(expected, 0));
}
}

View file

@ -12,7 +12,9 @@ import {DataHavenServiceManager} from "../src/DataHavenServiceManager.sol";
import {
TransparentUpgradeableProxy
} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
contract ValidatorSetSubmitterTest is SnowbridgeAndAVSDeployer {
address public submitterA = address(uint160(uint256(keccak256("submitterA"))));
@ -35,7 +37,8 @@ contract ValidatorSetSubmitterTest is SnowbridgeAndAVSDeployer {
== this.test_buildNewValidatorSetMessageForEra_exactEncoding.selector
) {
beforeTestCalldata = new bytes[](1);
beforeTestCalldata[0] = abi.encodeWithSelector(this.setupValidatorsAsOperators.selector);
beforeTestCalldata[0] =
abi.encodeWithSelector(this.setupValidatorsAsOperatorsWithAllocations.selector);
}
}
@ -179,7 +182,8 @@ contract ValidatorSetSubmitterTest is SnowbridgeAndAVSDeployer {
function test_sendNewValidatorSetForEra_revertsWhenSubmitterIsZeroAddress() public {
// Deploy a fresh proxy with address(0) as the submitter
IStrategy[] memory emptyStrategies = new IStrategy[](0);
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory emptyStrategies =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](0);
cheats.startPrank(regularDeployer);
DataHavenServiceManager zeroSubmitterSM = DataHavenServiceManager(

View file

@ -238,14 +238,6 @@ contract AVSDeployer is Test {
serviceManagerImplementation =
new DataHavenServiceManager(rewardsCoordinator, allocationManager);
// Create array for validators strategies required by DataHavenServiceManager
IStrategy[] memory validatorsStrategies = new IStrategy[](deployedStrategies.length);
// For testing purposes, we'll use the deployed strategies for validators
for (uint256 i = 0; i < deployedStrategies.length; i++) {
validatorsStrategies[i] = deployedStrategies[i];
}
serviceManager = DataHavenServiceManager(
address(
new TransparentUpgradeableProxy(
@ -255,7 +247,7 @@ contract AVSDeployer is Test {
DataHavenServiceManager.initialize.selector,
avsOwner,
rewardsInitiator,
validatorsStrategies,
defaultStrategyAndMultipliers,
address(snowbridgeGatewayMock),
avsOwner
)

View file

@ -16,6 +16,11 @@ import {TestUtils} from "./TestUtils.sol";
import {
IAllocationManagerTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
import {OperatorSet} from "eigenlayer-contracts/src/contracts/libraries/OperatorSetLib.sol";
import {ValidatorsUtils} from "../../script/utils/ValidatorsUtils.sol";
import {console} from "forge-std/Test.sol";
@ -145,6 +150,61 @@ contract SnowbridgeAndAVSDeployer is AVSDeployer {
serviceManager.setSnowbridgeGateway(address(gateway));
}
function setupValidatorsAsOperatorsWithAllocations() public {
setupValidatorsAsOperators();
// Remove strategies added during initialize (without multipliers)
// and re-add them with explicit multipliers
IStrategy[] memory strategies = new IStrategy[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
strategies[i] = deployedStrategies[i];
}
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sm =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
sm[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: strategies[i],
multiplier: 1 // 1x multiplier for all strategies
});
}
cheats.startPrank(avsOwner);
serviceManager.removeStrategiesFromValidatorsSupportedStrategies(strategies);
serviceManager.addStrategiesToValidatorsSupportedStrategies(sm);
cheats.stopPrank();
// Advance past ALLOCATION_CONFIGURATION_DELAY (1 day = 86400 blocks in test setup)
// so operator allocation delays take effect
uint32 allocationConfigDelay = allocationManager.ALLOCATION_CONFIGURATION_DELAY();
cheats.roll(block.number + allocationConfigDelay + 1);
// For each operator, allocate full magnitude to the DataHaven operator set
for (uint256 i = 0; i < validatorsAllowlist.length; i++) {
IAllocationManagerTypes.AllocateParams[] memory allocParams =
new IAllocationManagerTypes.AllocateParams[](1);
uint64[] memory newMagnitudes = new uint64[](deployedStrategies.length);
for (uint256 j = 0; j < deployedStrategies.length; j++) {
newMagnitudes[j] = 1e18; // 100% magnitude
}
allocParams[0] = IAllocationManagerTypes.AllocateParams({
operatorSet: OperatorSet({
avs: address(serviceManager), id: serviceManager.VALIDATORS_SET_ID()
}),
strategies: strategies,
newMagnitudes: newMagnitudes
});
cheats.prank(validatorsAllowlist[i]);
allocationManager.modifyAllocations(validatorsAllowlist[i], allocParams);
}
// Advance past allocation effect delay (operator delay is 0, so just +1 block)
cheats.roll(block.number + 1);
}
function setupValidatorsAsOperators() public {
for (uint256 i = 0; i < validatorsAllowlist.length; i++) {
console.log("Setting up validator %s as operator", validatorsAllowlist[i]);

View file

@ -0,0 +1,247 @@
# 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.

View file

@ -2047,6 +2047,13 @@ export const dataHavenServiceManagerAbi = [
outputs: [{ name: '', internalType: 'string', type: 'string' }],
stateMutability: 'view',
},
{
type: 'function',
inputs: [],
name: 'MAX_ACTIVE_VALIDATORS',
outputs: [{ name: '', internalType: 'uint32', type: 'uint32' }],
stateMutability: 'view',
},
{
type: 'function',
inputs: [],
@ -2058,9 +2065,17 @@ export const dataHavenServiceManagerAbi = [
type: 'function',
inputs: [
{
name: '_strategies',
internalType: 'contract IStrategy[]',
type: 'address[]',
name: '_strategyMultipliers',
internalType: 'struct IRewardsCoordinatorTypes.StrategyAndMultiplier[]',
type: 'tuple[]',
components: [
{
name: 'strategy',
internalType: 'contract IStrategy',
type: 'address',
},
{ name: 'multiplier', internalType: 'uint96', type: 'uint96' },
],
},
],
name: 'addStrategiesToValidatorsSupportedStrategies',
@ -2102,15 +2117,44 @@ export const dataHavenServiceManagerAbi = [
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
inputs: [],
name: 'getStrategiesAndMultipliers',
outputs: [
{
name: '',
internalType: 'struct IRewardsCoordinatorTypes.StrategyAndMultiplier[]',
type: 'tuple[]',
components: [
{
name: 'strategy',
internalType: 'contract IStrategy',
type: 'address',
},
{ name: 'multiplier', internalType: 'uint96', type: 'uint96' },
],
},
],
stateMutability: 'view',
},
{
type: 'function',
inputs: [
{ name: 'initialOwner', internalType: 'address', type: 'address' },
{ name: '_rewardsInitiator', internalType: 'address', type: 'address' },
{
name: 'validatorsStrategies',
internalType: 'contract IStrategy[]',
type: 'address[]',
name: 'validatorsStrategiesAndMultipliers',
internalType: 'struct IRewardsCoordinatorTypes.StrategyAndMultiplier[]',
type: 'tuple[]',
components: [
{
name: 'strategy',
internalType: 'contract IStrategy',
type: 'address',
},
{ name: 'multiplier', internalType: 'uint96', type: 'uint96' },
],
},
{
name: '_snowbridgeGatewayAddress',
@ -2213,6 +2257,27 @@ export const dataHavenServiceManagerAbi = [
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
inputs: [
{
name: '_strategyMultipliers',
internalType: 'struct IRewardsCoordinatorTypes.StrategyAndMultiplier[]',
type: 'tuple[]',
components: [
{
name: 'strategy',
internalType: 'contract IStrategy',
type: 'address',
},
{ name: 'multiplier', internalType: 'uint96', type: 'uint96' },
],
},
],
name: 'setStrategiesAndMultipliers',
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
inputs: [
@ -2252,6 +2317,13 @@ export const dataHavenServiceManagerAbi = [
outputs: [{ name: '', internalType: 'address', type: 'address' }],
stateMutability: 'view',
},
{
type: 'function',
inputs: [{ name: '', internalType: 'contract IStrategy', type: 'address' }],
name: 'strategiesAndMultipliers',
outputs: [{ name: '', internalType: 'uint96', type: 'uint96' }],
stateMutability: 'view',
},
{
type: 'function',
inputs: [
@ -2498,6 +2570,27 @@ export const dataHavenServiceManagerAbi = [
],
name: 'SolochainAddressUpdated',
},
{
type: 'event',
anonymous: false,
inputs: [
{
name: 'strategyMultipliers',
internalType: 'struct IRewardsCoordinatorTypes.StrategyAndMultiplier[]',
type: 'tuple[]',
components: [
{
name: 'strategy',
internalType: 'contract IStrategy',
type: 'address',
},
{ name: 'multiplier', internalType: 'uint96', type: 'uint96' },
],
indexed: false,
},
],
name: 'StrategiesAndMultipliersSet',
},
{
type: 'event',
anonymous: false,
@ -2580,6 +2673,7 @@ export const dataHavenServiceManagerAbi = [
{ type: 'error', inputs: [], name: 'OnlyValidatorSetSubmitter' },
{ type: 'error', inputs: [], name: 'OperatorNotInAllowlist' },
{ type: 'error', inputs: [], name: 'SolochainAddressAlreadyAssigned' },
{ type: 'error', inputs: [], name: 'StrategyNotInOperatorSet' },
{ type: 'error', inputs: [], name: 'UnknownSolochainAddress' },
{ type: 'error', inputs: [], name: 'ZeroAddress' },
] as const
@ -10719,6 +10813,15 @@ export const readDataHavenServiceManagerDatahavenAvsMetadata =
functionName: 'DATAHAVEN_AVS_METADATA',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"MAX_ACTIVE_VALIDATORS"`
*/
export const readDataHavenServiceManagerMaxActiveValidators =
/*#__PURE__*/ createReadContract({
abi: dataHavenServiceManagerAbi,
functionName: 'MAX_ACTIVE_VALIDATORS',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"VALIDATORS_SET_ID"`
*/
@ -10737,6 +10840,15 @@ export const readDataHavenServiceManagerBuildNewValidatorSetMessageForEra =
functionName: 'buildNewValidatorSetMessageForEra',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"getStrategiesAndMultipliers"`
*/
export const readDataHavenServiceManagerGetStrategiesAndMultipliers =
/*#__PURE__*/ createReadContract({
abi: dataHavenServiceManagerAbi,
functionName: 'getStrategiesAndMultipliers',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"owner"`
*/
@ -10764,6 +10876,15 @@ export const readDataHavenServiceManagerSnowbridgeGateway =
functionName: 'snowbridgeGateway',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"strategiesAndMultipliers"`
*/
export const readDataHavenServiceManagerStrategiesAndMultipliers =
/*#__PURE__*/ createReadContract({
abi: dataHavenServiceManagerAbi,
functionName: 'strategiesAndMultipliers',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"supportsAVS"`
*/
@ -10933,6 +11054,15 @@ export const writeDataHavenServiceManagerSetSnowbridgeGateway =
functionName: 'setSnowbridgeGateway',
})
/**
* Wraps __{@link writeContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"setStrategiesAndMultipliers"`
*/
export const writeDataHavenServiceManagerSetStrategiesAndMultipliers =
/*#__PURE__*/ createWriteContract({
abi: dataHavenServiceManagerAbi,
functionName: 'setStrategiesAndMultipliers',
})
/**
* Wraps __{@link writeContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"setValidatorSetSubmitter"`
*/
@ -11101,6 +11231,15 @@ export const simulateDataHavenServiceManagerSetSnowbridgeGateway =
functionName: 'setSnowbridgeGateway',
})
/**
* Wraps __{@link simulateContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"setStrategiesAndMultipliers"`
*/
export const simulateDataHavenServiceManagerSetStrategiesAndMultipliers =
/*#__PURE__*/ createSimulateContract({
abi: dataHavenServiceManagerAbi,
functionName: 'setStrategiesAndMultipliers',
})
/**
* Wraps __{@link simulateContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"setValidatorSetSubmitter"`
*/
@ -11242,6 +11381,15 @@ export const watchDataHavenServiceManagerSolochainAddressUpdatedEvent =
eventName: 'SolochainAddressUpdated',
})
/**
* Wraps __{@link watchContractEvent}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `eventName` set to `"StrategiesAndMultipliersSet"`
*/
export const watchDataHavenServiceManagerStrategiesAndMultipliersSetEvent =
/*#__PURE__*/ createWatchContractEvent({
abi: dataHavenServiceManagerAbi,
eventName: 'StrategiesAndMultipliersSet',
})
/**
* Wraps __{@link watchContractEvent}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `eventName` set to `"ValidatorAddedToAllowlist"`
*/

View file

@ -7,11 +7,13 @@ import { $ } from "bun";
import {
allocationManagerAbi,
dataHavenServiceManagerAbi,
delegationManagerAbi
delegationManagerAbi,
strategyManagerAbi
} from "contract-bindings";
import { type Deployments, logger, waitForContainerToStart } from "utils";
import { DEFAULT_SUBSTRATE_WS_PORT } from "utils/constants";
import { getPublicPort } from "utils/docker";
import { erc20Abi } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import validatorSet from "../../configs/validator-set.json";
import type { LaunchedNetwork } from "../../launcher/types/launchedNetwork";
@ -120,9 +122,50 @@ export async function registerOperator(
const { connectors, deployments } = options;
const validator = getValidator(validatorName);
const account = privateKeyToAccount(validator.privateKey as `0x${string}`);
const { publicClient, walletClient } = connectors;
// Deposit tokens into deployed strategies
const deployedStrategies = deployments.DeployedStrategies ?? [];
for (const strategy of deployedStrategies) {
const balance = await publicClient.readContract({
address: strategy.underlyingToken as `0x${string}`,
abi: erc20Abi,
functionName: "balanceOf",
args: [account.address]
});
if (balance > 0n) {
const depositAmount = balance / 10n;
const approveHash = await walletClient.writeContract({
address: strategy.underlyingToken as `0x${string}`,
abi: erc20Abi,
functionName: "approve",
args: [deployments.StrategyManager, depositAmount],
account,
chain: null
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
const depositHash = await walletClient.writeContract({
address: deployments.StrategyManager,
abi: strategyManagerAbi,
functionName: "depositIntoStrategy",
args: [
strategy.address as `0x${string}`,
strategy.underlyingToken as `0x${string}`,
depositAmount
],
account,
chain: null
});
await publicClient.waitForTransactionReceipt({ hash: depositHash });
logger.debug(`Deposited ${depositAmount} tokens into strategy ${strategy.address}`);
}
}
// Register as EigenLayer operator
const operatorHash = await connectors.walletClient.writeContract({
const operatorHash = await walletClient.writeContract({
address: deployments.DelegationManager as `0x${string}`,
abi: delegationManagerAbi,
functionName: "registerAsOperator",
@ -131,7 +174,7 @@ export async function registerOperator(
chain: null
});
const operatorReceipt = await connectors.publicClient.waitForTransactionReceipt({
const operatorReceipt = await publicClient.waitForTransactionReceipt({
hash: operatorHash
});
if (operatorReceipt.status !== "success") {
@ -139,7 +182,7 @@ export async function registerOperator(
}
// Register for operator sets
const hash = await connectors.walletClient.writeContract({
const registerHash = await walletClient.writeContract({
address: deployments.AllocationManager as `0x${string}`,
abi: allocationManagerAbi,
functionName: "registerForOperatorSets",
@ -155,10 +198,40 @@ export async function registerOperator(
chain: null
});
const receipt = await connectors.publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
throw new Error(`Operator set registration failed: ${receipt.status}`);
const registerReceipt = await publicClient.waitForTransactionReceipt({ hash: registerHash });
if (registerReceipt.status !== "success") {
throw new Error(`Operator set registration failed: ${registerReceipt.status}`);
}
logger.debug(`Registered ${validatorName} as operator (gas: ${receipt.gasUsed})`);
// Allocate full magnitude to the validator operator set
const strategyAddresses = deployedStrategies.map((s) => s.address as `0x${string}`);
const newMagnitudes = strategyAddresses.map(() => BigInt(1e18));
const allocateHash = await walletClient.writeContract({
address: deployments.AllocationManager as `0x${string}`,
abi: allocationManagerAbi,
functionName: "modifyAllocations",
args: [
account.address,
[
{
operatorSet: {
avs: deployments.ServiceManager as `0x${string}`,
id: 0
},
strategies: strategyAddresses,
newMagnitudes
}
]
],
account,
chain: null
});
const allocateReceipt = await publicClient.waitForTransactionReceipt({ hash: allocateHash });
if (allocateReceipt.status !== "success") {
throw new Error(`Magnitude allocation failed: ${allocateReceipt.status}`);
}
logger.debug(`Registered ${validatorName} as operator (gas: ${registerReceipt.gasUsed})`);
}

View file

@ -69,6 +69,20 @@ describe("Validator Set Update", () => {
beforeAll(async () => {
deployments = await parseDeploymentsFile();
connectors = suite.getTestConnectors();
// Pause era rotation early so the active era stabilizes during tests 1-3 (~28s),
// avoiding the ~80s wait inside the cross-chain test.
// Tests 1-3 only touch Ethereum contracts and don't depend on era rotation.
const { dhApi } = connectors;
const pauseTx = dhApi.tx.Sudo.sudo({
call: dhApi.tx.ExternalValidators.force_era({
mode: { type: "ForceNone", value: undefined }
}).decodedCall
});
const pauseResult = await pauseTx.signAndSubmit(getPapiSigner("ALITH"));
if (!pauseResult.ok) {
throw new Error("Failed to pause era rotation");
}
});
it("should verify test environment", async () => {
@ -161,31 +175,18 @@ describe("Validator Set Update", () => {
async () => {
const { publicClient, walletClient, dhApi } = connectors;
// Pause era rotation so the active era doesn't advance while
// Snowbridge relays the message (relay latency > era duration with fast-runtime).
// DatahavenServiceManagerAddress is set during infrastructure setup by set-datahaven-parameters.
const setupTx = dhApi.tx.Sudo.sudo({
call: dhApi.tx.ExternalValidators.force_era({
mode: { type: "ForceNone", value: undefined }
}).decodedCall
});
const setupResult = await setupTx.signAndSubmit(getPapiSigner("ALITH"));
if (!setupResult.ok) {
throw new Error("Failed to pause era rotation");
}
// Wait for the active era to stabilize: ForceNone prevents new eras but
// an already-triggered era may still be pending activation at the next session boundary.
// Poll until CurrentEra == ActiveEra, meaning no pending era transition remains.
// Era rotation was paused in beforeAll. Wait for any pending transition to settle
// (ForceNone prevents new eras, but an in-progress one must finish first).
let stableEraIndex: number;
// eslint-disable-next-line no-constant-condition
while (true) {
await new Promise((r) => setTimeout(r, 12_000)); // ~2 substrate blocks
const activeEra = (await dhApi.query.ExternalValidators.ActiveEra.getValue())?.index ?? 0;
const currentEra = (await dhApi.query.ExternalValidators.CurrentEra.getValue()) ?? 0;
if (currentEra === activeEra) {
stableEraIndex = activeEra;
break;
}
await new Promise((r) => setTimeout(r, 6_000)); // ~1 substrate block
}
const targetEra = BigInt(stableEraIndex + 1);
@ -201,12 +202,22 @@ describe("Validator Set Update", () => {
chain: null
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
logger.info(
`sendNewValidatorSet tx status: ${receipt.status}, block: ${receipt.blockNumber}`
);
expect(receipt.status).toBe("success");
// Verify OutboundMessageAccepted event was emitted
const hasOutboundAccepted = (receipt.logs ?? []).some((log: any) => {
try {
const decoded = decodeEventLog({ abi: gatewayAbi, data: log.data, topics: log.topics });
const decoded = decodeEventLog({
abi: gatewayAbi,
data: log.data,
topics: log.topics
});
if (decoded.eventName === "OutboundMessageAccepted") {
logger.info(`OutboundMessageAccepted event: nonce=${(decoded.args as any)?.nonce}`);
}
return decoded.eventName === "OutboundMessageAccepted";
} catch {
return false;
@ -214,6 +225,7 @@ describe("Validator Set Update", () => {
});
expect(hasOutboundAccepted).toBe(true);
logger.info("Waiting for ExternalValidators.ExternalValidatorsSet event on DataHaven...");
// Wait for the validator set to be updated on Substrate
await waitForDataHavenEvent({
api: dhApi,

View file

@ -124,6 +124,29 @@ export const setupValidators = async (options: SetupValidatorsOptions): Promise<
logger.success(`Successfully registered validator ${validator.publicKey}`);
}
// Allocate stake for each validator (must run in a separate script because
// the allocation delay needs at least 1 block after registerAsOperator)
logger.info("📊 Allocating operator stake...");
for (const [i, validator] of validatorsToRegister.entries()) {
logger.info(`📊 Allocating stake for validator ${i} (${validator.publicKey})`);
const env = {
...process.env,
NETWORK: networkName,
OPERATOR_PRIVATE_KEY: validator.privateKey,
OPERATOR_SOLOCHAIN_ADDRESS: validator.solochainAddress || ""
};
const allocateCommand = `forge script script/transact/AllocateOperatorStake.s.sol --rpc-url ${rpcUrl} --broadcast --no-rpc-rate-limit --non-interactive`;
await runShellCommandWithLogger(allocateCommand, {
env,
cwd: "../contracts",
logLevel: "debug"
});
logger.success(`Successfully allocated stake for validator ${validator.publicKey}`);
}
return true;
};