mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +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>
101 lines
3.1 KiB
TypeScript
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}`;
|
|
}
|