datahaven/test/scripts/update-validator-set.ts
Ahmad Kaouk 401f646286
feat: automated validator set submission with era targeting (#433)
## 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>
2026-02-20 10:31:44 +01:00

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);
});
}