mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
## 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`
213 lines
8.1 KiB
TypeScript
213 lines
8.1 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import invariant from "tiny-invariant";
|
|
import { logger, runShellCommandWithLogger } from "../utils/index";
|
|
|
|
interface SetupValidatorsOptions {
|
|
rpcUrl: string;
|
|
validatorsConfig?: string; // Path to JSON config file with validator addresses
|
|
executeSignup?: boolean;
|
|
networkName?: string; // Network name for default deployment path
|
|
deploymentPath?: string; // Optional custom deployment path
|
|
}
|
|
|
|
/**
|
|
* JSON structure for validator configuration
|
|
*/
|
|
interface ValidatorConfig {
|
|
validators: {
|
|
publicKey: string;
|
|
privateKey: string;
|
|
solochainAddress?: string;
|
|
solochainPrivateKey?: string;
|
|
solochainAuthorityName: string;
|
|
}[];
|
|
notes?: string;
|
|
}
|
|
|
|
/**
|
|
* Registers validators in EigenLayer based on a configuration file.
|
|
* This function reads validator details (public/private keys, optional solochain addresses)
|
|
* from a JSON file. If `executeSignup` is true (or confirmed by user prompt),
|
|
* it iterates through the configured validators and runs the
|
|
* `script/transact/SignUpValidator.s.sol` forge script for each to register them.
|
|
* Environment variables `OPERATOR_PRIVATE_KEY`, `OPERATOR_SOLOCHAIN_ADDRESS`, and `NETWORK`
|
|
* are set for the forge script execution.
|
|
*
|
|
* @param options - Configuration options for the validator setup process.
|
|
* @param options.rpcUrl - The RPC URL for the Ethereum network to interact with.
|
|
* @param options.validatorsConfig - Optional path to the JSON file containing validator configurations.
|
|
* Defaults to `../configs/validator-set.json` relative to this script.
|
|
* @param options.executeSignup - Optional. If true, proceeds with registration. If false, skips.
|
|
* If undefined, the user is prompted to confirm registration.
|
|
* @param options.networkName - Optional network name used when executing underlying scripts (e.g., for setting the `NETWORK` environment variable).
|
|
* Defaults to "anvil".
|
|
* @returns A Promise resolving to `true` if the validator registration process was executed
|
|
* (for all configured validators), or `false` if the registration was skipped
|
|
* (either due to the `executeSignup` option or user declining the prompt).
|
|
*/
|
|
export const setupValidators = async (options: SetupValidatorsOptions): Promise<boolean> => {
|
|
const { rpcUrl, validatorsConfig, networkName = "anvil" } = options;
|
|
|
|
// Validate RPC URL
|
|
invariant(rpcUrl, "❌ RPC URL is required");
|
|
|
|
// Load validator configuration - use default path if not specified
|
|
const configPath = validatorsConfig || path.resolve(__dirname, "../configs/validator-set.json");
|
|
|
|
// Ensure the configuration file exists
|
|
if (!fs.existsSync(configPath)) {
|
|
logger.error(`Validator configuration file not found: ${configPath}`);
|
|
throw new Error("Validator configuration file is required");
|
|
}
|
|
|
|
// Load and validate the validator configuration
|
|
logger.debug(`Loading validator configuration from ${configPath}`);
|
|
let config: ValidatorConfig;
|
|
|
|
try {
|
|
const fileContent = fs.readFileSync(configPath, "utf8");
|
|
config = JSON.parse(fileContent);
|
|
} catch (error) {
|
|
logger.error(`Failed to parse validator config file: ${error}`);
|
|
throw new Error("Invalid JSON format in validator configuration file");
|
|
}
|
|
|
|
// Validate the validators array
|
|
if (!config.validators || !Array.isArray(config.validators) || config.validators.length === 0) {
|
|
logger.error("Invalid validator configuration: 'validators' array is missing or empty");
|
|
throw new Error("Validator configuration must contain a non-empty 'validators' array");
|
|
}
|
|
|
|
// Validate each validator entry
|
|
for (const [index, validator] of config.validators.entries()) {
|
|
if (!validator.publicKey) {
|
|
throw new Error(`Validator at index ${index} is missing 'publicKey'`);
|
|
}
|
|
if (!validator.privateKey) {
|
|
throw new Error(`Validator at index ${index} is missing 'privateKey'`);
|
|
}
|
|
if (!validator.publicKey.startsWith("0x")) {
|
|
throw new Error(`Validator publicKey at index ${index} must start with '0x'`);
|
|
}
|
|
if (!validator.privateKey.startsWith("0x")) {
|
|
throw new Error(`Validator privateKey at index ${index} must start with '0x'`);
|
|
}
|
|
}
|
|
|
|
// Filter to only alice and bob validators
|
|
const validatorsToRegister = config.validators.filter((v) =>
|
|
["alice", "bob"].includes((v.solochainAuthorityName || "").toLowerCase())
|
|
);
|
|
|
|
logger.info(`🔎 Registering ${validatorsToRegister.length} validators`);
|
|
|
|
// Iterate through validators to register them
|
|
for (const [i, validator] of validatorsToRegister.entries()) {
|
|
logger.info(`🔧 Setting up validator ${i} (${validator.publicKey})`);
|
|
|
|
const env = {
|
|
...process.env,
|
|
NETWORK: networkName,
|
|
// OPERATOR_PRIVATE_KEY is what the script reads to set the operator
|
|
OPERATOR_PRIVATE_KEY: validator.privateKey,
|
|
// OPERATOR_SOLOCHAIN_ADDRESS is the validator's address on the substrate chain
|
|
OPERATOR_SOLOCHAIN_ADDRESS: validator.solochainAddress || ""
|
|
};
|
|
|
|
// Prepare command to register validator
|
|
const signupCommand = `forge script script/transact/SignUpValidator.s.sol --rpc-url ${rpcUrl} --broadcast --no-rpc-rate-limit --non-interactive`;
|
|
logger.debug(`Running command: ${signupCommand}`);
|
|
|
|
await runShellCommandWithLogger(signupCommand, { env, cwd: "../contracts", logLevel: "debug" });
|
|
|
|
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;
|
|
};
|
|
|
|
// Allow script to be run directly with CLI arguments
|
|
if (import.meta.main) {
|
|
const args = process.argv.slice(2);
|
|
const options: {
|
|
rpcUrl?: string;
|
|
validatorsConfig?: string;
|
|
executeSignup?: boolean;
|
|
networkName?: string;
|
|
deploymentPath?: string;
|
|
} = {
|
|
executeSignup: args.includes("--no-signup") ? false : undefined,
|
|
networkName: "anvil" // Default network name
|
|
};
|
|
|
|
// Extract RPC URL
|
|
const rpcUrlIndex = args.indexOf("--rpc-url");
|
|
if (rpcUrlIndex !== -1 && rpcUrlIndex + 1 < args.length) {
|
|
options.rpcUrl = args[rpcUrlIndex + 1];
|
|
}
|
|
|
|
// Extract validators config path
|
|
const configIndex = args.indexOf("--config");
|
|
if (configIndex !== -1 && configIndex + 1 < args.length) {
|
|
options.validatorsConfig = args[configIndex + 1];
|
|
}
|
|
|
|
// Extract network name
|
|
const networkIndex = args.indexOf("--network");
|
|
if (networkIndex !== -1 && networkIndex + 1 < args.length) {
|
|
options.networkName = args[networkIndex + 1];
|
|
}
|
|
|
|
// Extract custom deployment path
|
|
const deploymentPathIndex = args.indexOf("--deployment-path");
|
|
if (deploymentPathIndex !== -1 && deploymentPathIndex + 1 < args.length) {
|
|
options.deploymentPath = args[deploymentPathIndex + 1];
|
|
}
|
|
|
|
// Parse signup flag
|
|
if (args.includes("--signup")) {
|
|
options.executeSignup = true;
|
|
}
|
|
|
|
// Check required parameters
|
|
if (!options.rpcUrl) {
|
|
console.error("Error: --rpc-url parameter is required");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Run setup
|
|
setupValidators({
|
|
rpcUrl: options.rpcUrl,
|
|
validatorsConfig: options.validatorsConfig,
|
|
executeSignup: options.executeSignup,
|
|
networkName: options.networkName,
|
|
deploymentPath: options.deploymentPath
|
|
}).catch((error) => {
|
|
console.error("Validator setup failed:", error);
|
|
process.exit(1);
|
|
});
|
|
}
|