mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
## Era-targeted validator set submission with dedicated submitter role > **Note:** This PR includes a detailed specification at [`specs/validator-set-submission/validator-set-submission.md`](https://github.com/datahaven-xyz/datahaven/blob/feat/validator-set-submitter/specs/validator-set-submission/validator-set-submission.md) that covers the design rationale, submission lifecycle, era-targeting rules, and failure modes. Reading the spec first will make the contract, pallet, and daemon changes easier to follow. ### Summary - Introduce a dedicated `validatorSetSubmitter` role on `DataHavenServiceManager`, separating validator set submission authority from the contract owner - Replace the unscoped `sendNewValidatorSet` with `sendNewValidatorSetForEra`, which encodes a `targetEra` into the Snowbridge message payload - Add server-side era validation in the `external-validators` pallet to reject stale, duplicate, or out-of-range submissions - Add a long-running TypeScript daemon that watches session changes and automatically submits each era's validator set at the right time ### Contract changes (`contracts/`) - **New `validatorSetSubmitter` storage slot** — set during `initialize` and rotatable via `setValidatorSetSubmitter` (owner-only). The storage gap is decremented accordingly. - **`sendNewValidatorSet` → `sendNewValidatorSetForEra`** — accepts a `uint64 targetEra` parameter and is restricted to `onlyValidatorSetSubmitter` instead of `onlyOwner`. - **`buildNewValidatorSetMessageForEra`** — the `NewValidatorSetPayload.externalIndex` is now caller-supplied instead of hardcoded to `0`. - **New events** — `ValidatorSetSubmitterUpdated`, `ValidatorSetMessageSubmitted`. - **New error** — `OnlyValidatorSetSubmitter`. - **New test suite** — `ValidatorSetSubmitter.t.sol` covering submitter set/rotate, access control, era encoding, and legacy function removal. ### Pallet changes (`operator/`) - **`validate_target_era`** in `external-validators` — enforces `activeEra < targetEra <= activeEra + 1` and `targetEra > ExternalIndex` (dedup guard). - **New errors** — `TargetEraTooOld`, `TargetEraTooNew`, `DuplicateOrStaleTargetEra`. - **Tests** — five new test cases for era boundary conditions (next-era acceptance, old-era rejection, too-new rejection, duplicate rejection, genesis behavior). Existing `era_hooks_with_external_index` test updated to use valid target eras. - **Runtime test fixes** — `external_index: 0` → `1` in mainnet/stagenet/testnet EigenLayer message processor tests to satisfy the new validation. ### Validator set submitter daemon (`test/tools/validator-set-submitter/`) - Event-driven service that subscribes to finalized `Session.CurrentIndex` via Polkadot-API `watchValue`. - Submits once per era during the last session, targeting `ActiveEra + 1`. - Tracks submitted eras to avoid duplicates; skips if `ExternalIndex` already covers the target. - Startup self-checks: Ethereum connectivity, DataHaven connectivity, on-chain submitter authorization. - Supports `--dry-run` mode and YAML configuration. - Graceful shutdown on `SIGINT`/`SIGTERM`. ### Test & tooling updates - **E2E test** (`validator-set-update.test.ts`) — calls `sendNewValidatorSetForEra` with a computed `targetEra` and filters the substrate event by `external_index`. - **`update-validator-set.ts` script** — accepts `--target-era` flag; defaults to era 1 for fresh networks. - **CLI launch** — wires validator set update as an interactive step after relayer launch. - **`package.json`** — new `submitter` and `submitter:dry-run` scripts. - Regenerated contract bindings, PAPI metadata, state-diff, and storage layout snapshots. ### Test plan - [x] `forge test` — passes, including new `ValidatorSetSubmitter.t.sol` - [x] `cargo test` — passes, including new era-validation tests in `external-validators` - [x] `bun test:e2e` — validator-set-update suite passes with era-targeted flow - [x] Manual: run submitter daemon against local network (`bun submitter`), verify it submits once per era at the correct session ## ⚠️ Breaking Changes ⚠️ - **`sendNewValidatorSet` removed** — replaced by `sendNewValidatorSetForEra(uint64 targetEra, ...)`. Callers must now supply a `targetEra` parameter. - **Access control changed** — validator set submission is now restricted to the `validatorSetSubmitter` role instead of the contract `owner`. The submitter address is set during `initialize` and rotatable via `setValidatorSetSubmitter` (owner-only). - **`external-validators` pallet now validates `targetEra`** — messages with a stale, duplicate, or out-of-range `external_index` are rejected on-chain. Existing integrations sending `external_index: 0` will fail validation. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
// Update validator set on DataHaven substrate chain
|
|
import { $ } from "bun";
|
|
import invariant from "tiny-invariant";
|
|
import { logger } from "../utils/index";
|
|
|
|
interface UpdateValidatorSetOptions {
|
|
rpcUrl: string;
|
|
targetEra?: bigint;
|
|
}
|
|
|
|
/**
|
|
* Sends the validator set to the DataHaven chain through Snowbridge
|
|
*
|
|
* @param options - Configuration options for update
|
|
* @param options.rpcUrl - The RPC URL to connect to
|
|
* @returns Promise resolving to true if validator set was sent successfully, false if skipped
|
|
*/
|
|
export const updateValidatorSet = async (options: UpdateValidatorSetOptions): Promise<boolean> => {
|
|
const { rpcUrl } = options;
|
|
|
|
// Validate RPC URL
|
|
invariant(rpcUrl, "❌ RPC URL is required");
|
|
|
|
// Get cast path for transactions
|
|
const { stdout: castPath } = await $`which cast`.quiet();
|
|
const castExecutable = castPath.toString().trim();
|
|
|
|
// Get the owner's private key for transaction signing from the .env
|
|
const ownerPrivateKey =
|
|
process.env.AVS_OWNER_PRIVATE_KEY ||
|
|
"0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e"; // Sixth pre-funded account from Anvil
|
|
|
|
// Get deployed contract addresses from the deployments file
|
|
const deploymentPath = path.resolve("../contracts/deployments/anvil.json");
|
|
|
|
if (!fs.existsSync(deploymentPath)) {
|
|
logger.error(`Deployment file not found: ${deploymentPath}`);
|
|
return false;
|
|
}
|
|
|
|
const deployments = JSON.parse(fs.readFileSync(deploymentPath, "utf8"));
|
|
|
|
// Prepare command to send validator set
|
|
const serviceManagerAddress = deployments.ServiceManager;
|
|
invariant(serviceManagerAddress, "ServiceManager address not found in deployments");
|
|
|
|
// Using cast to send the transaction
|
|
const executionFee = "100000000000000000"; // 0.1 ETH
|
|
const relayerFee = "200000000000000000"; // 0.2 ETH
|
|
const value = "300000000000000000"; // 0.3 ETH (sum of fees)
|
|
const targetEra = options.targetEra ?? 1n;
|
|
|
|
if (options.targetEra === undefined) {
|
|
logger.warn(
|
|
"No target era specified; defaulting to era 1. Use --target-era for already-running networks."
|
|
);
|
|
}
|
|
|
|
const sendCommand = `${castExecutable} send --private-key ${ownerPrivateKey} --value ${value} ${serviceManagerAddress} "sendNewValidatorSetForEra(uint64,uint128,uint128)" ${targetEra} ${executionFee} ${relayerFee} --rpc-url ${rpcUrl}`;
|
|
|
|
logger.debug(`Running command: ${sendCommand}`);
|
|
|
|
const { exitCode, stderr } = await $`sh -c ${sendCommand}`.nothrow().quiet();
|
|
|
|
if (exitCode !== 0) {
|
|
logger.error(`Failed to send validator set: ${stderr.toString()}`);
|
|
return false;
|
|
}
|
|
|
|
logger.success("Validator set sent to Snowbridge Gateway");
|
|
|
|
// Check if the validator set has been queued on the substrate side (placeholder)
|
|
logger.debug("Checking validator set on substrate chain (not implemented)");
|
|
/*
|
|
// PLACEHOLDER: Code to check if validator set has been queued on substrate
|
|
// This requires a connection to the DataHaven substrate node which is not available yet
|
|
|
|
// Example of what this might look like:
|
|
const substrateApi = await ApiPromise.create({ provider: new WsProvider('ws://localhost:9944') });
|
|
const validatorSetModule = substrateApi.query.validatorSet;
|
|
const queuedValidators = await validatorSetModule.queuedValidators();
|
|
|
|
if (queuedValidators.length === validators.length) {
|
|
logger.success('Validator set successfully queued on substrate chain');
|
|
} else {
|
|
logger.warn('Validator set not properly queued on substrate chain');
|
|
}
|
|
*/
|
|
|
|
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;
|
|
targetEra?: bigint;
|
|
} = {};
|
|
|
|
// Extract RPC URL
|
|
const rpcUrlIndex = args.indexOf("--rpc-url");
|
|
if (rpcUrlIndex !== -1 && rpcUrlIndex + 1 < args.length) {
|
|
options.rpcUrl = args[rpcUrlIndex + 1];
|
|
}
|
|
|
|
// Extract target era
|
|
const targetEraIndex = args.indexOf("--target-era");
|
|
if (targetEraIndex !== -1 && targetEraIndex + 1 < args.length) {
|
|
options.targetEra = BigInt(args[targetEraIndex + 1]);
|
|
}
|
|
|
|
// Check required parameters
|
|
if (!options.rpcUrl) {
|
|
console.error("Error: --rpc-url parameter is required");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Run update
|
|
updateValidatorSet({
|
|
rpcUrl: options.rpcUrl,
|
|
targetEra: options.targetEra
|
|
}).catch((error) => {
|
|
console.error("Validator set update failed:", error);
|
|
process.exit(1);
|
|
});
|
|
}
|