## 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>
14 KiB
Validator Set Submission
Status: Accepted Owner: DataHaven Protocol / AVS Integration Last Updated: 2026-02-11 Scope: Ethereum -> Snowbridge -> DataHaven validator set synchronization
Background
This specification defines an automation-first validator-set synchronization flow. In this document:
- the validator-set submitter runs once per era window, and
- each message is valid only for the immediate next era. The primary objective is to run an off-chain validator-set-submitter that automatically calls validator-set submission without manual intervention. The design is:
- Validator-set messages are permissioned on Ethereum by a dedicated submitter role.
- The payload field
external_indexis used astargetEra(the era the message is intended for). - DataHaven accepts a message only if it targets the next era at receive time.
- Delayed messages for past eras are rejected and never applied to later eras. This enforces the invariant: at most one canonical validator-set apply per target era, and no late-era spillover.
Current mechanism (as-is)
- Manual and one-shot submission flow is done via
test/scripts/update-validator-set.ts. sendNewValidatorSet(uint128 executionFee, uint128 relayerFee)incontracts/src/DataHavenServiceManager.solis owner-only.- Message building currently does not carry explicit era intent.
- DataHaven inbound processing applies decoded
external_indexwithout era-target validation. - Operational flow relies on fixed fee constants and has no automated submission pipeline.
Problems addressed by this spec
- Manual operation for validator-set submission.
- Late relay can cause old messages to arrive after their intended era.
- Ambiguity between "message order" and "era intent".
- Owner-key usage for routine automated submissions.
Goals
- Run an off-chain component that automatically submits validator-set updates in the required era window.
- Ensure each message is explicitly bound to a specific target era.
- Accept a message only when it targets the immediate next era.
- Reject delayed (past-era), duplicate, and too-far-ahead messages deterministically.
- Accept that a failed submission for a given era is permanently missed (single submission window per era).
- Avoid skipping era advancement even when validator addresses are unchanged.
Non-goals
- Redesigning Snowbridge protocol internals.
- Replacing the existing owner/governance model outside submitter assignment.
- Building a multi-node HA control plane (single submitter process is acceptable initially).
Terminology
ActiveEra: era currently active on DataHaven.NextEra:ActiveEra + 1.targetEra: era this validator-set message is intended for.external_index: payload field; in this design, its value istargetEra.ExternalIndex: latest bridge-receivedtargetEraaccepted on DataHaven.PendingExternalIndex: staged external index applied when the next era starts.CurrentExternalIndex: external index currently applied to the active era.Canonical apply: the accepted validator-set apply for a specifictargetEra.
Proposed design
High-level overview
The solution centers on a long-running off-chain validator-set-submitter under test/tools/ that automatically submits validator-set updates.
Contract and runtime changes make the submitter service safe and deterministic:
- only the submitter role can send validator-set messages,
- payloads include explicit era intent (
targetEra), and - DataHaven accepts only messages targeting
NextEra. The submitter subscribes to finalized session changes via PAPI'swatchValue("finalized")onSession.CurrentIndex. On each session change it evaluates whether submission is needed, and acts during the last session of the active era. Each era gets a single submission attempt — if it fails, the era is missed and the submitter moves on.
┌───────────────────────────────┐ submit (for era) ┌───────────────────────────────┐
│ Validator-Set-Submitter │ ──────────────────────────► │ ServiceManager (Ethereum) │
│ - watches session changes │ │ - submitter-gated API │
│ - computes targetEra │ │ - builds payload with target │
│ - single attempt per era │ └───────────────┬───────────────┘
└───────────────────────────────┘ │
│ Snowbridge message
▼
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ DataHaven inbound (`operator/primitives/bridge`) + external validators pallet │
│ - authorized origin check │
│ - era gate: targetEra == ActiveEra + 1 │
│ - duplicate/stale gate: targetEra > ExternalIndex │
│ - delayed messages for past eras are rejected │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
A) Ethereum contract changes
Target contract
contracts/src/DataHavenServiceManager.sol
Permissioned submitter role
- Add state:
address public validatorSetSubmitter
- Add admin API:
setValidatorSetSubmitter(address newSubmitter) external onlyOwnernewSubmitterMUST be non-zero- emit
ValidatorSetSubmitterUpdated(oldSubmitter, newSubmitter)
- Add modifier:
onlyValidatorSetSubmitter(revert unlessmsg.sender == validatorSetSubmitter)
Era-targeted submission
- Add submission API:
sendNewValidatorSetForEra(uint64 targetEra, uint128 executionFee, uint128 relayerFee) external payable onlyValidatorSetSubmitter- builds validator payload with
targetEra - calls gateway
v2_sendMessage - emits
ValidatorSetMessageSubmitted
- Add builder API:
buildNewValidatorSetMessageForEra(uint64 targetEra) public view returns (bytes memory)- encodes
targetEraasexternal_index
Legacy submission path
- Legacy
sendNewValidatorSet(uint128,uint128)must be removed from the production contract.
Contract-side trust scope (this release)
- No additional
lastSubmittedTargetEracontract guard is required in this release. - Rationale: submission is permissioned and runtime is the source of truth for era correctness (
targetEra == ActiveEra + 1).
Events
event ValidatorSetSubmitterUpdated(address indexed oldSubmitter, address indexed newSubmitter);event ValidatorSetMessageSubmitted(uint64 indexed targetEra, bytes32 payloadHash, address indexed submitter);
B) Runtime changes (DataHaven)
Target processor
operator/primitives/bridge/src/lib.rsinEigenLayerMessageProcessor::process_message
Era-target validation rule
Before set_external_validators_inner, validate targetEra:
- Must satisfy
targetEra == ActiveEra + 1 - Must satisfy
targetEra > ExternalIndex(dedupe/stale guard) Reject cases:
targetEra <= ActiveEra: delayed/past-era message.targetEra > ActiveEra + 1: too-far-ahead message.targetEra <= ExternalIndex: stale/duplicate message. This ensures a delayed message cannot be applied to a later era.
Error semantics Return deterministic dispatch errors, for example:
TargetEraTooOldTargetEraTooNewDuplicateOrStaleTargetEra
Authorization
- Keep existing authorized-origin checks unchanged.
C) Validator-set-submitter service (test/tools/)
Location and runtime model
- New component at
test/tools/validator-set-submitter/ - Long-running daemon
- TypeScript + Bun
Authoritative inputs
- DataHaven:
ActiveEraExternalIndexCurrentExternalIndexSessionsPerEraand era-window session boundaries
- Ethereum:
- current validator set view from ServiceManager message-builder inputs
Target era computation
targetEra = ActiveEra + 1
Submission model
- Submitter subscribes to finalized
Session.CurrentIndexvia PAPIwatchValue("finalized"). - On each session change, evaluates preconditions:
ActiveEraset,targetEranot already processed,ExternalIndex < targetEra, and current session is the last session of the era. - One submission attempt per era window. If the attempt fails (revert, missing event, or error), the era is marked as processed and permanently missed.
- Rationale:
validate_target_eraon the Substrate side rejectstargetEra <= activeEraIndex, so onceActiveEraadvances past the target, retries are impossible. - Overlapping session emissions are dropped via RxJS
exhaustMap.
Delay/gap behavior (required)
- If message for era
Nis delayed and arrives afterActiveEra >= N, it is rejected. - If message for era
Nnever relays, the system can still proceed by submitting for eraN+1whenActiveEra = N. - Out-of-order future messages are rejected until they become the next era target.
Success criteria
- Transaction receipt status is
success. OutboundMessageAcceptedevent emitted in receipt logs.
State model
- Submitter is recoverable from chain state (reads
ActiveEra,ExternalIndex, and session boundaries on each tick). - In-memory state is limited to
submittedEra(the last processed target era), held in a closure.
API / interface changes
Ethereum interface
- Add era-targeted submit function.
- Add submitter admin function + getter.
- Add era-targeted builder function.
DataHaven runtime behavior
- Add next-era-only acceptance in inbound bridge path.
- Add explicit delayed/too-early/duplicate rejection paths.
Tooling
- New daemon CLI entrypoint:
bun test/tools/validator-set-submitter/main.ts run- optional
--dry-run
Security considerations
- Submitter key compromise risk is reduced by dedicated role separation (vs broad owner use).
- Era-target checks prevent delayed-message replay into later eras.
- Authorized-origin restriction remains required and unchanged.
- Single-attempt model eliminates fee burn loops; a failed era is missed rather than retried.
Observability and operations
Required metrics/log dimensions:
targetEra- current
ActiveEraandExternalIndex - current session index
- outbound tx hash
- fee pair used
- submission outcome (success / revert / missing event / error) Alert conditions:
- missed submission window (failed attempt logged as "era will be missed")
- repeated era misses across consecutive eras
- subscription errors on
Session.CurrentIndex
Testing
Solidity tests
- submitter-only enforcement
- submitter rotation by owner
- payload encodes caller
targetEra - event fields emitted correctly
- zero-address submitter rejected
- legacy
sendNewValidatorSetpath is removed (no callable legacy submit path)
Runtime tests
- accepts only
targetEra == ActiveEra + 1 - rejects
targetEra <= ActiveEra(late) - rejects
targetEra > ActiveEra + 1(too early) - rejects
targetEra <= ExternalIndex(duplicate/stale) - origin authorization behavior unchanged
Integration tests
- one canonical apply per target era
- delayed message for old era is rejected after era advances
- missing relay for era
Ndoes not block acceptance for eraN+1when it becomes next - boundary race: arrival at era transition behaves correctly (
Nstale,N+1accepted)
Rollout
- Implement and test contract + runtime changes.
- Deploy to stagenet.
- Run submitter service in dry-run mode and validate era-target decisions.
- Enable active mode.
- Monitor across multiple era cycles.
- Promote to mainnet after stability criteria are met.
Dependencies
- Existing manual script
test/scripts/update-validator-set.tsmay remain for emergency/manual use, but must be marked non-canonical. - Legacy unscoped submit path
sendNewValidatorSetmust be removed in production.
Possible improvements (future)
- Keep this release simple:
external_indexcarriestargetEra, and runtime enforces next-era-only acceptance. - Add a generalized failure-handling strategy for the submitter, including retry behavior for transient issues while preserving safety and idempotency.
- Add generalized resiliency for event watching and connectivity, including recovery after disconnects and missed updates.
- Add production monitoring and operations dashboards (for example Prometheus/Grafana) covering service health, submission outcomes, retries, missed eras, and end-to-end latency.
- Add alerting/SLO definitions for validator-set submission reliability and response runbooks for incidents.
- Alternative direction: remove era dependency from payload and use an Ethereum-stamped freshness model:
ServiceManagerassigns message metadata on-chain (e.g.,issuedAttimestamp and monotonic message nonce/ID).- DataHaven accepts only fresh messages within a configured max relay delay and rejects expired ones.
- This reduces trust in submitter-provided era values while preserving deterministic stale/duplicate rejection.
Acceptance criteria
This spec is accepted when:
- an off-chain validator-set-submitter runs unattended and automatically submits validator-set updates
- dedicated submitter role exists and is enforced
- era-targeted submission API is live
- runtime applies messages only when they target the next era
- delayed messages for past eras are rejected and not applied to later eras
- end-to-end tests pass for delayed/missing/out-of-order scenarios