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

209 lines
6.1 KiB
TypeScript

import { EMPTY, exhaustMap } from "rxjs";
import { logger } from "utils/logger";
import { createPapiConnectors, type DataHavenApi } from "utils/papi";
import {
type Account,
createPublicClient,
createWalletClient,
decodeEventLog,
http,
type PublicClient,
type WalletClient
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { dataHavenServiceManagerAbi, gatewayAbi } from "../../contract-bindings";
import { computeTargetEra, getActiveEra, getExternalIndex, isLastSessionOfEra } from "./chain";
import type { SubmitterConfig } from "./config";
interface SubmitterClients {
publicClient: PublicClient;
walletClient: WalletClient<ReturnType<typeof http>, undefined, Account>;
dhApi: DataHavenApi;
papiClient: ReturnType<typeof createPapiConnectors>["client"];
}
const RECEIPT_TIMEOUT_MS = 120_000;
export function createClients(config: SubmitterConfig): SubmitterClients {
const account = privateKeyToAccount(config.submitterPrivateKey);
const transport = http(config.ethereumRpcUrl);
const publicClient = createPublicClient({ transport });
const walletClient = createWalletClient({ account, transport });
const { client: papiClient, typedApi: dhApi } = createPapiConnectors(config.datahavenWsUrl);
return { publicClient, walletClient, dhApi, papiClient };
}
/**
* Returns a promise that resolves when the signal is aborted.
*/
function onAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) =>
signal.addEventListener("abort", () => resolve(), { once: true })
);
}
/**
* Waits for a transaction receipt with a hard timeout, and exits early on abort.
*/
async function waitForReceiptWithAbort(
publicClient: PublicClient,
hash: `0x${string}`,
signal: AbortSignal
) {
return Promise.race([
publicClient.waitForTransactionReceipt({
hash,
timeout: RECEIPT_TIMEOUT_MS
}),
onAbort(signal).then(() => {
throw signal.reason ?? new Error("Aborted while waiting for transaction receipt");
})
]);
}
/**
* Creates a tick handler that closes over submission state.
* Each call evaluates a session change and submits if eligible.
*/
function createTicker(clients: SubmitterClients, config: SubmitterConfig, signal: AbortSignal) {
let submittedEra: bigint | undefined;
return async (currentSession: number): Promise<void> => {
const { dhApi } = clients;
const activeEra = await getActiveEra(dhApi);
if (!activeEra) {
logger.warn("ActiveEra not set yet");
return;
}
const targetEra = computeTargetEra(activeEra.index);
if (submittedEra === targetEra) return;
const externalIndex = await getExternalIndex(dhApi);
if (externalIndex >= targetEra) {
submittedEra = targetEra;
return;
}
if (!(await isLastSessionOfEra(dhApi))) return;
logger.info(
`Session=${currentSession} ActiveEra=${activeEra.index} TargetEra=${targetEra} ExternalIndex=${externalIndex}`
);
const succeeded = await submitForEra(clients, config, targetEra, signal);
if (succeeded) submittedEra = targetEra;
};
}
/**
* Watches finalized session changes and submits validator sets when eligible.
* Runs until the signal is aborted.
*/
export async function startSubmitter(
clients: SubmitterClients,
config: SubmitterConfig,
signal: AbortSignal
): Promise<void> {
const { dhApi } = clients;
const tick = createTicker(clients, config, signal);
logger.info("Submitter started — watching session changes");
const sub = dhApi.query.Session.CurrentIndex.watchValue("finalized")
.pipe(
exhaustMap((currentSession) => {
if (signal.aborted) return EMPTY;
return tick(currentSession).catch((err) => {
if (!signal.aborted) logger.error(`Tick error: ${err}`);
});
})
)
.subscribe({
error: (err) => {
if (!signal.aborted) logger.error(`Session subscription error: ${err}`);
}
});
const done = new Promise<void>((resolve) => sub.add(() => resolve()));
await Promise.race([onAbort(signal), done]);
sub.unsubscribe();
logger.info("Submitter stopped");
}
/**
* Submits the validator set for a single target era.
* Logs success or failure internally.
*/
async function submitForEra(
clients: SubmitterClients,
config: SubmitterConfig,
targetEra: bigint,
signal: AbortSignal
): Promise<boolean> {
const { publicClient, walletClient } = clients;
const totalFee = config.executionFee + config.relayerFee;
logger.info(
`Submitting era ${targetEra} (execFee=${config.executionFee} relayerFee=${config.relayerFee})`
);
if (config.dryRun) {
const message = await publicClient.readContract({
address: config.serviceManagerAddress,
abi: dataHavenServiceManagerAbi,
functionName: "buildNewValidatorSetMessageForEra",
args: [targetEra]
});
logger.info(`[DRY RUN] Would send message: ${message}`);
return true;
}
try {
const hash = await walletClient.writeContract({
address: config.serviceManagerAddress,
abi: dataHavenServiceManagerAbi,
functionName: "sendNewValidatorSetForEra",
args: [targetEra, config.executionFee, config.relayerFee],
value: totalFee,
chain: null
});
logger.info(`Transaction sent: ${hash}`);
const receipt = await waitForReceiptWithAbort(publicClient, hash, signal);
if (receipt.status !== "success") {
logger.error(`Transaction reverted: ${hash}`);
return false;
}
const hasOutbound = receipt.logs.some((log) => {
try {
const decoded = decodeEventLog({
abi: gatewayAbi,
data: log.data,
topics: log.topics
});
return decoded.eventName === "OutboundMessageAccepted";
} catch {
return false;
}
});
if (!hasOutbound) {
logger.warn("Transaction succeeded but no OutboundMessageAccepted event found");
return false;
}
logger.info("OutboundMessageAccepted confirmed");
return true;
} catch (err: unknown) {
if (signal.aborted) return false;
logger.error(`Submission attempt failed: ${err}`);
return false;
}
}