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>
209 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|