datahaven/contracts/test/utils/SnowbridgeAndAVSDeployer.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

258 lines
12 KiB
Solidity

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.27;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Gateway} from "snowbridge/src/Gateway.sol";
import {IGatewayV2} from "snowbridge/src/v2/IGateway.sol";
import {GatewayProxy} from "snowbridge/src/GatewayProxy.sol";
import {AgentExecutor} from "snowbridge/src/AgentExecutor.sol";
import {Agent} from "snowbridge/src/Agent.sol";
import {Initializer} from "snowbridge/src/Initializer.sol";
import {OperatingMode} from "snowbridge/src/types/Common.sol";
import {ud60x18} from "snowbridge/lib/prb-math/src/UD60x18.sol";
import {BeefyClient} from "snowbridge/src/BeefyClient.sol";
import {AVSDeployer} from "./AVSDeployer.sol";
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";
contract SnowbridgeAndAVSDeployer is AVSDeployer {
// Snowbridge contracts
BeefyClient public beefyClient;
IGatewayV2 public gateway;
Gateway public gatewayImplementation;
AgentExecutor public agentExecutor;
Agent public rewardsAgent;
Agent public wrongAgent;
// The addresses of the validators that are allowed to register to the DataHaven service.
address[] public validatorsAllowlist = [
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, // First pre-funded address in anvil
0x70997970C51812dc3A010C7d01b50e0d17dc79C8, // Second pre-funded address in anvil
0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC, // Third pre-funded address in anvil
0x90F79bf6EB2c4f870365E785982E1f101E93b906, // Fourth pre-funded address in anvil
0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65, // Fifth pre-funded address in anvil
0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc, // Sixth pre-funded address in anvil
0x976EA74026E726554dB657fA54763abd0C3a0aa9, // Seventh pre-funded address in anvil
0x14dC79964da2C08b23698B3D3cc7Ca32193d9955, // Eighth pre-funded address in anvil
0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f, // Ninth pre-funded address in anvil
0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 // Tenth pre-funded address in anvil
];
// Snowbridge contracts params
// The hashes of the initial (current) Validators in the DataHaven solochain.
bytes32[] public initialValidatorHashes;
// The hashes of the next Validators in the DataHaven solochain.
bytes32[] public nextValidatorHashes;
// In reality this should be set to MAX_SEED_LOOKAHEAD (4 epochs = 128 blocks/slots)
// https://eth2book.info/capella/part3/config/preset/#time-parameters
uint256 public constant RANDAO_COMMIT_DELAY = 4;
// In reality this is set to 24 blocks https://etherscan.io/address/0x6eD05bAa904df3DE117EcFa638d4CB84e1B8A00C#readContract#F10
uint256 public constant RANDAO_COMMIT_EXPIRATION = 24;
// In reality this is set to 17 https://etherscan.io/address/0x6eD05bAa904df3DE117EcFa638d4CB84e1B8A00C#readContract#F7
uint256 public constant MIN_NUM_REQUIRED_SIGNATURES = 2;
uint64 public constant START_BLOCK = 1;
bytes32 public constant REWARDS_MESSAGE_ORIGIN = bytes32(0);
// "wrong origin" as bytes32 (hex-encoded, right-padded with zeros)
bytes32 public constant WRONG_MESSAGE_ORIGIN =
0x77726f6e67206f726967696e0000000000000000000000000000000000000000;
function _deployMockAllContracts() internal {
_deployMockSnowbridge();
_deployMockEigenLayerAndAVS();
_connectSnowbridgeToAVS();
}
function _deployMockSnowbridge() internal {
// Generate validator arrays using the generator functions
initialValidatorHashes = TestUtils.generateMockValidators(10);
nextValidatorHashes = TestUtils.generateMockValidators(10, 10);
BeefyClient.ValidatorSet memory validatorSet =
ValidatorsUtils._buildValidatorSet(0, initialValidatorHashes);
BeefyClient.ValidatorSet memory nextValidatorSet =
ValidatorsUtils._buildValidatorSet(1, nextValidatorHashes);
cheats.prank(regularDeployer);
beefyClient = new BeefyClient(
RANDAO_COMMIT_DELAY,
RANDAO_COMMIT_EXPIRATION,
MIN_NUM_REQUIRED_SIGNATURES,
START_BLOCK,
validatorSet,
nextValidatorSet
);
console.log("BeefyClient deployed at", address(beefyClient));
cheats.prank(regularDeployer);
agentExecutor = new AgentExecutor();
console.log("AgentExecutor deployed at", address(agentExecutor));
cheats.prank(regularDeployer);
gatewayImplementation = new Gateway(address(beefyClient), address(agentExecutor));
console.log("GatewayImplementation deployed at", address(gatewayImplementation));
OperatingMode defaultOperatingMode = OperatingMode.Normal;
Initializer.Config memory config = Initializer.Config({
mode: defaultOperatingMode,
deliveryCost: 1, // This is for v1, we don't really care about this
registerTokenFee: 1, // This is for v1, we don't really care about this
assetHubCreateAssetFee: 1, // This is for v1, we don't really care about this
assetHubReserveTransferFee: 1, // This is for v1, we don't really care about this
exchangeRate: ud60x18(1), // This is for v1, we don't really care about this
multiplier: ud60x18(1), // This is for v1, we don't really care about this
foreignTokenDecimals: 18, // This is for v1, we don't really care about this
maxDestinationFee: 1 // This is for v1, we don't really care about this
});
cheats.prank(regularDeployer);
gateway = IGatewayV2(
address(new GatewayProxy(address(gatewayImplementation), abi.encode(config)))
);
console.log("Gateway deployed at", address(gateway));
}
function _connectSnowbridgeToAVS() internal {
cheats.prank(regularDeployer);
gateway.v2_createAgent(REWARDS_MESSAGE_ORIGIN);
// Get the agent address after creation.
address payable agentAddress = payable(gateway.agentOf(REWARDS_MESSAGE_ORIGIN));
rewardsAgent = Agent(agentAddress);
console.log("Rewards agent deployed at", address(rewardsAgent));
cheats.prank(regularDeployer);
gateway.v2_createAgent(WRONG_MESSAGE_ORIGIN);
// Get the agent address after creation.
address payable wrongAgentAddress = payable(gateway.agentOf(WRONG_MESSAGE_ORIGIN));
wrongAgent = Agent(wrongAgentAddress);
console.log("Wrong agent deployed at", address(wrongAgent));
// Set the Snowbridge Gateway address in the DataHaven service.
cheats.prank(avsOwner);
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]);
// Whitelist the validator in the DataHaven service.
cheats.prank(avsOwner);
serviceManager.addValidatorToAllowlist(validatorsAllowlist[i]);
cheats.startPrank(validatorsAllowlist[i]);
for (uint256 j = 0; j < deployedStrategies.length; j++) {
console.log(
"Depositing tokens from validator %s into strategy %s",
validatorsAllowlist[i],
address(deployedStrategies[j])
);
// Give the validator some balance in the strategy's linked token.
IERC20 linkedToken = deployedStrategies[j].underlyingToken();
_setERC20Balance(address(linkedToken), validatorsAllowlist[i], 1000 ether);
// Stake some of the validator's balance as stake for the strategy.
linkedToken.approve(address(strategyManager), 1000 ether);
strategyManager.depositIntoStrategy(deployedStrategies[j], linkedToken, 1000 ether);
console.log(
"Staked %s tokens from validator %s into strategy %s",
1000 ether,
validatorsAllowlist[i],
address(deployedStrategies[j])
);
}
// Register the validator as an operator in EigenLayer.
delegationManager.registerAsOperator(address(0), 0, "");
// Register the validator as an operator for the DataHaven service.
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(address(uint160(uint256(initialValidatorHashes[i]))))
});
allocationManager.registerForOperatorSets(validatorsAllowlist[i], registerParams);
cheats.stopPrank();
console.log("Validator %s setup as operator", validatorsAllowlist[i]);
}
}
}