datahaven/test/scripts/set-datahaven-parameters.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

102 lines
3 KiB
TypeScript

import { parseArgs } from "node:util";
import { datahaven } from "@polkadot-api/descriptors";
import { createClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { getEvmEcdsaSigner, logger, SUBSTRATE_FUNDED_ACCOUNTS } from "utils";
import { parseJsonToParameters } from "utils/types";
/**
* Sets DataHaven runtime parameters on the specified RPC URL from a JSON file.
*/
export const setDataHavenParameters = async (
rpcUrl: string,
parametersFilePath: string
): Promise<boolean> => {
const parametersJson = await Bun.file(parametersFilePath).json();
const parameters = parseJsonToParameters(parametersJson).filter((p) => p.value !== undefined);
if (parameters.length === 0) {
logger.warn("⚠️ No parameters to set.");
return false;
}
const client = createClient(withPolkadotSdkCompat(getWsProvider(rpcUrl)));
try {
const dhApi = client.getTypedApi(datahaven);
const signer = getEvmEcdsaSigner(SUBSTRATE_FUNDED_ACCOUNTS.ALITH.privateKey);
// Log parameters being set
for (const p of parameters) {
logger.debug(`🔧 Setting ${p.name} = ${p.value!.asHex()}`);
}
// Build parameter calls
const calls = parameters.map(
(p) =>
dhApi.tx.Parameters.set_parameter({
key_value: {
type: "RuntimeConfig",
value: { type: p.name, value: [p.value] }
}
}).decodedCall
);
// Batch all calls and wrap in sudo
const tx = dhApi.tx.Sudo.sudo({
call: dhApi.tx.Utility.batch_all({ calls }).decodedCall
});
const result = await tx.signAndSubmit(signer);
// sudo always returns Ok at the extrinsic level — check the Sudid event
// for the inner call result
const sudidEvent = result.events.find(
(e: any) => e.type === "Sudo" && e.value?.type === "Sudid"
);
if (!sudidEvent) {
logger.error("❌ Sudo.Sudid event not found in transaction events");
return false;
}
const sudoResult = (sudidEvent.value as any).value.sudo_result;
if (sudoResult.type === "Err") {
logger.error(`❌ Sudo inner call failed: ${JSON.stringify(sudoResult)}`);
return false;
}
logger.success("Runtime parameters set successfully");
return true;
} catch (error) {
logger.error(`${error instanceof Error ? error.message : error}`);
return false;
} finally {
client.destroy();
}
};
// CLI entry point
if (import.meta.main) {
const { values } = parseArgs({
args: process.argv,
options: {
rpcUrl: { type: "string", short: "r" },
parametersFile: { type: "string", short: "f" }
},
strict: true
});
if (!values.rpcUrl || !values.parametersFile) {
console.error("Usage: --rpc-url <url> --parameters-file <path>");
process.exit(1);
}
setDataHavenParameters(values.rpcUrl, values.parametersFile)
.then((ok) => process.exit(ok ? 0 : 1))
.catch((e) => {
console.error(e);
process.exit(1);
});
}