datahaven/test/tools/validator-set-submitter/config.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

101 lines
3.1 KiB
TypeScript

import { parseDeploymentsFile } from "utils";
import { parseEther } from "viem";
import { parse as parseYaml } from "yaml";
export interface SubmitterConfig {
ethereumRpcUrl: string;
datahavenWsUrl: string;
submitterPrivateKey: `0x${string}`;
serviceManagerAddress: `0x${string}`;
networkId: string;
executionFee: bigint;
relayerFee: bigint;
dryRun: boolean;
}
interface CliOverrides {
dryRun?: boolean;
submitterPrivateKey?: string;
}
export async function loadConfig(
configPath: string,
cli: CliOverrides = {}
): Promise<SubmitterConfig> {
const file = Bun.file(configPath);
if (!(await file.exists())) {
throw new Error(`Config file not found: ${configPath}`);
}
const raw = parseYaml(await file.text()) as Record<string, unknown>;
const ethereumRpcUrl = requireString(raw, "ethereum_rpc_url");
const datahavenWsUrl = requireString(raw, "datahaven_ws_url");
const submitterPrivateKey = resolveSubmitterPrivateKey(raw, cli.submitterPrivateKey);
const networkId = optionalString(raw, "network_id") ?? "anvil";
let serviceManagerAddress = optionalHexString(raw, "service_manager_address");
if (!serviceManagerAddress) {
const deployments = await parseDeploymentsFile(networkId);
serviceManagerAddress = deployments.ServiceManager;
}
const executionFee = parseEther(optionalString(raw, "execution_fee") ?? "0.1");
const relayerFee = parseEther(optionalString(raw, "relayer_fee") ?? "0.2");
return {
ethereumRpcUrl,
datahavenWsUrl,
submitterPrivateKey,
serviceManagerAddress,
networkId,
executionFee,
relayerFee,
dryRun: cli.dryRun ?? false
};
}
function resolveSubmitterPrivateKey(
raw: Record<string, unknown>,
cliPrivateKey?: string
): `0x${string}` {
const submitterPrivateKey =
cliPrivateKey ??
process.env.SUBMITTER_PRIVATE_KEY ??
optionalString(raw, "submitter_private_key");
if (!submitterPrivateKey || submitterPrivateKey.length === 0) {
throw new Error(
"Missing submitter private key. Provide --submitter-private-key, SUBMITTER_PRIVATE_KEY, or submitter_private_key in config."
);
}
if (!/^0x[0-9a-fA-F]{64}$/.test(submitterPrivateKey)) {
throw new Error("Submitter private key must be a 66-character hex string (0x + 64 hex chars)");
}
return submitterPrivateKey as `0x${string}`;
}
function requireString(raw: Record<string, unknown>, key: string): string {
const val = raw[key];
if (typeof val !== "string" || val.length === 0) {
throw new Error(`Missing required config field: ${key}`);
}
return val;
}
function optionalString(raw: Record<string, unknown>, key: string): string | undefined {
const val = raw[key];
if (val === undefined || val === null) return undefined;
if (typeof val !== "string") return String(val);
return val;
}
function optionalHexString(raw: Record<string, unknown>, key: string): `0x${string}` | undefined {
const val = optionalString(raw, key);
if (!val) return undefined;
if (!val.startsWith("0x")) {
throw new Error(`Config field ${key} must start with 0x`);
}
return val as `0x${string}`;
}