datahaven/test/scripts/deploy-contracts.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

352 lines
11 KiB
TypeScript

import { $ } from "bun";
import { CHAIN_CONFIGS, loadChainConfig } from "configs/contracts/config";
import invariant from "tiny-invariant";
import { logger, parseDeploymentsFile, runShellCommandWithLogger } from "utils";
import type { ParameterCollection } from "utils/parameters";
import { encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { dataHavenServiceManagerAbi } from "../contract-bindings/generated";
interface ContractDeploymentOptions {
chain?: string;
environment?: string;
rpcUrl?: string;
privateKey?: string | undefined;
verified?: boolean;
blockscoutBackendUrl?: string;
avsOwnerAddress?: string;
avsOwnerKey?: string;
txExecution?: boolean;
}
/**
* Builds the network identifier from chain and optional environment
* When environment is specified: {environment}-{chain} (e.g., "stagenet-hoodi")
* When environment is not specified: {chain} (e.g., "hoodi")
*/
export const buildNetworkId = (chain: string, environment?: string): string => {
return environment ? `${environment}-${chain}` : chain;
};
/**
* Validates deployment parameters
*/
export const validateDeploymentParams = (options: ContractDeploymentOptions) => {
const { rpcUrl, verified, blockscoutBackendUrl } = options;
invariant(rpcUrl, "❌ RPC URL is required");
if (verified) {
invariant(blockscoutBackendUrl, "❌ Blockscout backend URL is required for verification");
}
};
/**
* Builds smart contracts using forge
*/
export const buildContracts = async () => {
logger.info("🛳️ Building contracts...");
const {
exitCode: buildExitCode,
stderr: buildStderr,
stdout: buildStdout
} = await $`forge build`.cwd("../contracts").nothrow().quiet();
if (buildExitCode !== 0) {
logger.error(buildStderr.toString());
throw Error("❌ Contracts have failed to build properly.");
}
logger.debug(buildStdout.toString());
};
/**
* Constructs the deployment command
*/
export const constructDeployCommand = (options: ContractDeploymentOptions): string => {
const { chain, environment, rpcUrl, verified, blockscoutBackendUrl } = options;
const deploymentScript =
!chain || chain === "anvil"
? "script/deploy/DeployLocal.s.sol"
: "script/deploy/DeployLive.s.sol";
// Build the network identifier for display and environment variable
const networkId = buildNetworkId(chain || "anvil", environment);
logger.info(`🚀 Deploying contracts to ${networkId} using ${deploymentScript}`);
let deployCommand = `forge script ${deploymentScript} --rpc-url ${rpcUrl} --color never -vv --no-rpc-rate-limit --non-interactive --broadcast`;
// Add environment variables for network (used by Solidity scripts for config/output file naming)
if (chain) {
deployCommand = `NETWORK=${networkId} ${deployCommand}`;
}
if (verified && blockscoutBackendUrl) {
// TODO: Allow for other verifiers like Etherscan.
deployCommand += ` --verify --verifier blockscout --verifier-url ${blockscoutBackendUrl}/api/ --delay 0`;
logger.info("🔍 Contract verification enabled");
}
return deployCommand;
};
/**
* Executes contract deployment
* Supports multiple calling patterns for backwards compatibility:
*/
export const executeDeployment = async (
deployCommand: string,
parameterCollection?: ParameterCollection,
chain?: string,
env?: Record<string, string>
) => {
logger.info("⌛️ Deploying contracts (this might take a few minutes)...");
// Using custom shell command to improve logging with forge's stdoutput
await runShellCommandWithLogger(deployCommand, {
cwd: "../contracts",
env
});
// After deployment, read the Gateway address and add it to parameters if collection is provided
if (parameterCollection) {
await updateParameters(parameterCollection, chain);
}
logger.success("Contracts deployed successfully");
};
/**
* Read the parameters from the deployed contracts and add it to the collection.
*/
export const updateParameters = async (
parameterCollection: ParameterCollection,
chain?: string
) => {
const deployments = await parseDeploymentsFile(chain);
const gatewayAddress = deployments.Gateway;
const serviceManagerAddress = deployments.ServiceManager;
if (gatewayAddress) {
logger.debug(`📝 Adding EthereumGatewayAddress parameter: ${gatewayAddress}`);
parameterCollection.addParameter({
name: "EthereumGatewayAddress",
value: gatewayAddress
});
} else {
logger.warn("⚠️ Gateway address not found in deployments file");
}
if (serviceManagerAddress) {
logger.debug(`📝 Adding DatahavenServiceManagerAddress parameter: ${serviceManagerAddress}`);
parameterCollection.addParameter({
name: "DatahavenServiceManagerAddress",
value: serviceManagerAddress
});
} else {
logger.warn("⚠️ ServiceManager address not found in deployments file");
}
};
/**
* Main function to deploy contracts with simplified interface
* This is the main entry point for CLI handlers
*/
export const deployContracts = async (options: {
chain: string;
environment?: string;
rpcUrl?: string;
privateKey?: string | undefined;
verified?: boolean;
blockscoutBackendUrl?: string;
avsOwnerKey?: string;
avsOwnerAddress?: string;
txExecution?: boolean;
}) => {
const chainConfig = CHAIN_CONFIGS[options.chain as keyof typeof CHAIN_CONFIGS];
if (!chainConfig) {
throw new Error(`Unsupported chain: ${options.chain}`);
}
// Build network identifier for config/deployment file naming
const networkId = buildNetworkId(options.chain, options.environment);
const finalRpcUrl = options.rpcUrl || chainConfig.RPC_URL;
const isLocalChain = options.chain === "anvil";
const txExecutionEnabled = options.txExecution ?? isLocalChain;
const normalizedOwnerKey = normalizePrivateKey(
options.avsOwnerKey || process.env.AVS_OWNER_PRIVATE_KEY
);
let resolvedAvsOwnerAddress = options.avsOwnerAddress;
if (!resolvedAvsOwnerAddress && normalizedOwnerKey) {
resolvedAvsOwnerAddress = privateKeyToAccount(normalizedOwnerKey).address;
}
if (!resolvedAvsOwnerAddress && isLocalChain) {
const config = await loadChainConfig(options.chain, options.environment);
resolvedAvsOwnerAddress = config?.avs?.avsOwner;
}
if (!resolvedAvsOwnerAddress) {
throw new Error(
"AVS owner address is required. Provide --avs-owner-address, --avs-owner-key, or AVS_OWNER_ADDRESS."
);
}
if (txExecutionEnabled && !normalizedOwnerKey) {
throw new Error(
"Executing AVS owner transactions requires --avs-owner-key or AVS_OWNER_PRIVATE_KEY to be set."
);
}
const deploymentOptions: ContractDeploymentOptions = {
chain: options.chain,
environment: options.environment,
rpcUrl: finalRpcUrl,
privateKey: options.privateKey,
verified: options.verified,
blockscoutBackendUrl: options.blockscoutBackendUrl,
avsOwnerAddress: resolvedAvsOwnerAddress,
avsOwnerKey: normalizedOwnerKey,
txExecution: txExecutionEnabled
};
// Validate parameters
validateDeploymentParams(deploymentOptions);
// Build contracts
await buildContracts();
// Construct and execute deployment
const deployCommand = constructDeployCommand(deploymentOptions);
const env = buildDeploymentEnv(deploymentOptions);
await executeDeployment(deployCommand, undefined, networkId, env);
if (!txExecutionEnabled) {
await emitOwnerTransactionCalldata(networkId);
}
logger.success(`DataHaven contracts deployed successfully to ${networkId}`);
};
const normalizePrivateKey = (key?: string): `0x${string}` | undefined => {
if (!key) {
return undefined;
}
return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`;
};
const buildDeploymentEnv = (options: ContractDeploymentOptions) => {
const env: Record<string, string> = {};
if (options.privateKey) {
env.DEPLOYER_PRIVATE_KEY = options.privateKey;
}
if (options.avsOwnerKey) {
env.AVS_OWNER_PRIVATE_KEY = options.avsOwnerKey;
}
if (options.avsOwnerAddress) {
env.AVS_OWNER_ADDRESS = options.avsOwnerAddress;
}
if (typeof options.txExecution === "boolean") {
env.TX_EXECUTION = options.txExecution ? "true" : "false";
}
return env;
};
const emitOwnerTransactionCalldata = async (chain?: string) => {
try {
const deployments = await parseDeploymentsFile(chain);
const serviceManager = deployments.ServiceManager;
if (!serviceManager) {
logger.warn("⚠️ Missing ServiceManager address; cannot produce multisig calldata.");
return;
}
const calls = [
{
label: "Set metadata URI",
description: 'DataHavenServiceManager.updateAVSMetadataURI("")',
to: serviceManager,
value: "0",
data: encodeFunctionData({
abi: dataHavenServiceManagerAbi,
functionName: "updateAVSMetadataURI",
args: [""]
})
}
];
logger.info(
"🔐 On-chain owner transactions were deferred. Submit the following calls via your multisig:"
);
calls.forEach((call, index) => {
logger.info(`\n#${index + 1} ${call.label}`);
logger.info(call.description);
logger.info(JSON.stringify(call, null, 2));
});
} catch (error) {
logger.warn(`⚠️ Failed to build multisig calldata: ${error}`);
}
};
// Allow script to be run directly with CLI arguments
if (import.meta.main) {
const args = process.argv.slice(2);
// Extract RPC URL
const rpcUrlIndex = args.indexOf("--rpc-url");
invariant(rpcUrlIndex !== -1, "❌ --rpc-url flag is required");
invariant(rpcUrlIndex + 1 < args.length, "❌ --rpc-url flag requires an argument");
// Extract private key
const privateKeyIndex = args.indexOf("--private-key");
invariant(privateKeyIndex !== -1, "❌ --private-key flag is required");
invariant(privateKeyIndex + 1 < args.length, "❌ --private-key flag requires an argument");
const options: {
rpcUrl: string;
privateKey: string;
verified: boolean;
blockscoutBackendUrl?: string;
} = {
rpcUrl: args[rpcUrlIndex + 1],
privateKey: args[privateKeyIndex + 1],
verified: args.includes("--verified")
};
// Extract Blockscout URL if verification is enabled
if (options.verified) {
const blockscoutUrlIndex = args.indexOf("--blockscout-url");
if (blockscoutUrlIndex !== -1 && blockscoutUrlIndex + 1 < args.length) {
options.blockscoutBackendUrl = args[blockscoutUrlIndex + 1];
}
}
if (!options.rpcUrl) {
console.error("Error: --rpc-url parameter is required");
process.exit(1);
}
if (options.verified && !options.blockscoutBackendUrl) {
console.error("Error: --blockscout-url parameter is required when using --verified");
process.exit(1);
}
validateDeploymentParams(options);
await buildContracts();
const deployCommand = constructDeployCommand(options);
const directEnv = options.privateKey ? { DEPLOYER_PRIVATE_KEY: options.privateKey } : undefined;
await executeDeployment(deployCommand, undefined, undefined, directEnv);
}