datahaven/contracts/test/ValidatorSetSubmitter.t.sol
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

287 lines
11 KiB
Solidity

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
/* solhint-disable func-name-mixedcase */
import {SnowbridgeAndAVSDeployer} from "./utils/SnowbridgeAndAVSDeployer.sol";
import {
IDataHavenServiceManagerErrors,
IDataHavenServiceManagerEvents
} from "../src/interfaces/IDataHavenServiceManager.sol";
import {DataHavenServiceManager} from "../src/DataHavenServiceManager.sol";
import {
TransparentUpgradeableProxy
} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
contract ValidatorSetSubmitterTest is SnowbridgeAndAVSDeployer {
address public submitterA = address(uint160(uint256(keccak256("submitterA"))));
address public submitterB = address(uint160(uint256(keccak256("submitterB"))));
address public nonOwner = address(uint160(uint256(keccak256("nonOwner"))));
function setUp() public {
_deployMockAllContracts();
}
function beforeTestSetup(
bytes4 testSelector
) public pure returns (bytes[] memory beforeTestCalldata) {
if (
testSelector == this.test_sendNewValidatorSetForEra_success.selector
|| testSelector
== this.test_buildNewValidatorSetMessageForEra_encodesTargetEra.selector
|| testSelector == this.test_fuzz_sendNewValidatorSetForEra.selector
|| testSelector
== this.test_buildNewValidatorSetMessageForEra_exactEncoding.selector
) {
beforeTestCalldata = new bytes[](1);
beforeTestCalldata[0] =
abi.encodeWithSelector(this.setupValidatorsAsOperatorsWithAllocations.selector);
}
}
// ============ setValidatorSetSubmitter ============
function test_setValidatorSetSubmitter() public {
// After initialization, validatorSetSubmitter is already set to avsOwner
assertEq(
serviceManager.validatorSetSubmitter(),
avsOwner,
"validatorSetSubmitter should be set to avsOwner after init"
);
cheats.expectEmit();
emit IDataHavenServiceManagerEvents.ValidatorSetSubmitterUpdated(avsOwner, submitterA);
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
assertEq(
serviceManager.validatorSetSubmitter(),
submitterA,
"validatorSetSubmitter should be set"
);
}
function test_setValidatorSetSubmitter_revertsIfNotOwner() public {
cheats.prank(nonOwner);
cheats.expectRevert();
serviceManager.setValidatorSetSubmitter(submitterA);
}
function test_setValidatorSetSubmitter_revertsOnZeroAddress() public {
cheats.prank(avsOwner);
cheats.expectRevert(
abi.encodeWithSelector(IDataHavenServiceManagerErrors.ZeroAddress.selector)
);
serviceManager.setValidatorSetSubmitter(address(0));
}
function test_setValidatorSetSubmitter_rotation() public {
// Set submitter A (rotating from avsOwner set during init)
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
assertEq(serviceManager.validatorSetSubmitter(), submitterA);
// Rotate to submitter B
cheats.expectEmit();
emit IDataHavenServiceManagerEvents.ValidatorSetSubmitterUpdated(submitterA, submitterB);
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterB);
assertEq(serviceManager.validatorSetSubmitter(), submitterB);
// Old submitter A can no longer submit
vm.deal(submitterA, 10 ether);
cheats.prank(submitterA);
cheats.expectRevert(
abi.encodeWithSelector(
IDataHavenServiceManagerErrors.OnlyValidatorSetSubmitter.selector
)
);
serviceManager.sendNewValidatorSetForEra{value: 2 ether}(1, 1 ether, 1 ether);
}
// ============ sendNewValidatorSetForEra ============
function test_sendNewValidatorSetForEra_revertsIfNotSubmitter() public {
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
vm.deal(nonOwner, 10 ether);
cheats.prank(nonOwner);
cheats.expectRevert(
abi.encodeWithSelector(
IDataHavenServiceManagerErrors.OnlyValidatorSetSubmitter.selector
)
);
serviceManager.sendNewValidatorSetForEra{value: 2 ether}(1, 1 ether, 1 ether);
}
function test_sendNewValidatorSetForEra_success() public {
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
uint64 targetEra = 42;
vm.deal(submitterA, 1000000 ether);
bytes memory message = serviceManager.buildNewValidatorSetMessageForEra(targetEra);
bytes32 expectedHash = keccak256(message);
cheats.expectEmit();
emit IDataHavenServiceManagerEvents.ValidatorSetMessageSubmitted(
targetEra, expectedHash, submitterA
);
cheats.prank(submitterA);
serviceManager.sendNewValidatorSetForEra{value: 2 ether}(targetEra, 1 ether, 1 ether);
}
function test_sendNewValidatorSetForEra_revertsOnEmptyValidatorSet() public {
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
vm.deal(submitterA, 10 ether);
cheats.prank(submitterA);
cheats.expectRevert(
abi.encodeWithSelector(IDataHavenServiceManagerErrors.EmptyValidatorSet.selector)
);
serviceManager.sendNewValidatorSetForEra{value: 2 ether}(1, 1 ether, 1 ether);
}
function test_ownerCannotCallSendNewValidatorSetForEra() public {
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
vm.deal(avsOwner, 10 ether);
cheats.prank(avsOwner);
cheats.expectRevert(
abi.encodeWithSelector(
IDataHavenServiceManagerErrors.OnlyValidatorSetSubmitter.selector
)
);
serviceManager.sendNewValidatorSetForEra{value: 2 ether}(1, 1 ether, 1 ether);
}
// ============ buildNewValidatorSetMessageForEra ============
function test_buildNewValidatorSetMessageForEra_encodesTargetEra() public view {
bytes memory messageEra1 = serviceManager.buildNewValidatorSetMessageForEra(1);
bytes memory messageEra2 = serviceManager.buildNewValidatorSetMessageForEra(2);
bytes memory messageEra100 = serviceManager.buildNewValidatorSetMessageForEra(100);
// Different era values must produce different encoded output
assertTrue(
keccak256(messageEra1) != keccak256(messageEra2),
"Messages for different eras should differ"
);
assertTrue(
keccak256(messageEra1) != keccak256(messageEra100),
"Messages for different eras should differ"
);
}
function test_sendNewValidatorSetForEra_revertsWhenSubmitterIsZeroAddress() public {
// Deploy a fresh proxy with address(0) as the submitter
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory emptyStrategies =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](0);
cheats.startPrank(regularDeployer);
DataHavenServiceManager zeroSubmitterSM = DataHavenServiceManager(
address(
new TransparentUpgradeableProxy(
address(serviceManagerImplementation),
address(proxyAdmin),
abi.encodeWithSelector(
DataHavenServiceManager.initialize.selector,
avsOwner,
rewardsInitiator,
emptyStrategies,
address(snowbridgeGatewayMock),
address(0)
)
)
)
);
cheats.stopPrank();
assertEq(
zeroSubmitterSM.validatorSetSubmitter(),
address(0),
"validatorSetSubmitter should be address(0)"
);
vm.deal(submitterA, 10 ether);
cheats.prank(submitterA);
cheats.expectRevert(
abi.encodeWithSelector(
IDataHavenServiceManagerErrors.OnlyValidatorSetSubmitter.selector
)
);
zeroSubmitterSM.sendNewValidatorSetForEra{value: 2 ether}(1, 1 ether, 1 ether);
}
function test_fuzz_sendNewValidatorSetForEra(
uint64 targetEra
) public {
cheats.prank(avsOwner);
serviceManager.setValidatorSetSubmitter(submitterA);
vm.deal(submitterA, 1000000 ether);
bytes memory message = serviceManager.buildNewValidatorSetMessageForEra(targetEra);
bytes32 expectedHash = keccak256(message);
cheats.expectEmit();
emit IDataHavenServiceManagerEvents.ValidatorSetMessageSubmitted(
targetEra, expectedHash, submitterA
);
cheats.prank(submitterA);
serviceManager.sendNewValidatorSetForEra{value: 2 ether}(targetEra, 1 ether, 1 ether);
}
function test_buildNewValidatorSetMessageForEra_exactEncoding() public view {
uint64 targetEra = 42;
bytes memory message = serviceManager.buildNewValidatorSetMessageForEra(targetEra);
// Total: 4 (EL_MESSAGE_ID) + 1 (V0) + 1 (ReceiveValidators)
// + 1 (compact 10) + 10*20 (validators) + 8 (era) = 215
assertEq(message.length, 215, "Message length should be 215 bytes");
// First 4 bytes: EL_MESSAGE_ID = 0x70150038
assertEq(uint8(message[0]), 0x70, "EL_MESSAGE_ID byte 0");
assertEq(uint8(message[1]), 0x15, "EL_MESSAGE_ID byte 1");
assertEq(uint8(message[2]), 0x00, "EL_MESSAGE_ID byte 2");
assertEq(uint8(message[3]), 0x38, "EL_MESSAGE_ID byte 3");
// Byte 4: V0 = 0x00
assertEq(uint8(message[4]), 0x00, "V0 byte mismatch");
// Byte 5: ReceiveValidators = 0x00
assertEq(uint8(message[5]), 0x00, "ReceiveValidators byte mismatch");
// Byte 6: SCALE compact encoding of 10 validators = 10 << 2 = 40 = 0x28
assertEq(uint8(message[6]), 0x28, "Compact encoding of 10 validators");
// Last 8 bytes: era 42 in SCALE little-endian = 0x2A00000000000000
assertEq(uint8(message[207]), 0x2A, "Era LE byte 0");
assertEq(uint8(message[208]), 0x00, "Era LE byte 1");
assertEq(uint8(message[209]), 0x00, "Era LE byte 2");
assertEq(uint8(message[210]), 0x00, "Era LE byte 3");
assertEq(uint8(message[211]), 0x00, "Era LE byte 4");
assertEq(uint8(message[212]), 0x00, "Era LE byte 5");
assertEq(uint8(message[213]), 0x00, "Era LE byte 6");
assertEq(uint8(message[214]), 0x00, "Era LE byte 7");
}
// ============ Legacy function removed ============
function test_legacySendNewValidatorSet_removed() public {
// The old sendNewValidatorSet(uint128,uint128) selector should not be callable
bytes memory callData =
abi.encodeWithSelector(bytes4(keccak256("sendNewValidatorSet(uint128,uint128)")), 1, 1);
vm.deal(avsOwner, 10 ether);
cheats.prank(avsOwner);
(bool success,) = address(serviceManager).call{value: 2 ether}(callData);
assertFalse(success, "Legacy sendNewValidatorSet should not be callable");
}
}