mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
test: integrate validator-set-submitter Docker container into E2E test (#453)
## Summary - Replace the manual `sendNewValidatorSetForEra` contract call in the `validator-set-update` E2E test with the **validator-set-submitter daemon** running as a Docker container - Add `test/e2e/framework/submitter.ts` with helpers to build the image, launch the container on the shared Docker network, and clean up after the test - Remove unused imports (`getOwnerAccount`, `decodeEventLog`, `parseEther`, `gatewayAbi`) that were only needed for manual submission The submitter automatically detects the last session of an era and submits the validator set via Snowbridge, matching production behavior more closely than the previous manual call. ## Test plan - [x] `bun test e2e/suites/validator-set-update.test.ts --timeout 900000` passes (4/4 tests, 15 assertions) - [x] Verify submitter container starts and connects to both Ethereum and DataHaven - [x] Verify `ExternalValidatorsSet` event is observed on DataHaven - [x] Verify submitter container is cleaned up after the test - [x] Verify Charlie and Dave appear in the final validator set
This commit is contained in:
parent
49286b128d
commit
39aea69e36
3 changed files with 169 additions and 42 deletions
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./connectors";
|
||||
export * from "./manager";
|
||||
export * from "./submitter";
|
||||
export * from "./suite";
|
||||
export * from "./validators";
|
||||
|
|
|
|||
135
test/e2e/framework/submitter.ts
Normal file
135
test/e2e/framework/submitter.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* E2E test helper for managing the validator-set-submitter Docker container.
|
||||
*
|
||||
* The submitter daemon automates `sendNewValidatorSetForEra` calls on the
|
||||
* ServiceManager contract. This module builds the image, launches the
|
||||
* container on the shared Docker network, and tears it down after the test.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { ANVIL_FUNDED_ACCOUNTS, logger, waitForContainerToStart, waitForLog } from "utils";
|
||||
import { RELAYER_CONFIG_DIR } from "../../launcher/relayers";
|
||||
|
||||
const SUBMITTER_IMAGE = "datahavenxyz/validator-set-submitter:local";
|
||||
const SUBMITTER_READY_LOG = "Submitter started — watching session changes";
|
||||
const SUBMITTER_READY_TIMEOUT_SECONDS = 30;
|
||||
const SUBMITTER_LOG_TAIL_LINES = 200;
|
||||
|
||||
/**
|
||||
* Builds the validator-set-submitter Docker image from the repo root.
|
||||
*/
|
||||
export async function buildSubmitterImage(): Promise<void> {
|
||||
logger.debug("Building validator-set-submitter Docker image...");
|
||||
const repoRoot = path.resolve(import.meta.dir, "../../..");
|
||||
await $`docker build -f test/tools/validator-set-submitter/Dockerfile -t ${SUBMITTER_IMAGE} .`
|
||||
.cwd(repoRoot)
|
||||
.quiet();
|
||||
logger.debug("Validator-set-submitter image built successfully");
|
||||
}
|
||||
|
||||
export interface LaunchSubmitterOptions {
|
||||
/** Docker network name (from launchedNetwork.networkName) */
|
||||
networkName: string;
|
||||
/** Network ID for container naming */
|
||||
networkId: string;
|
||||
/** Host-facing Ethereum RPC URL (e.g. http://127.0.0.1:32000) */
|
||||
ethereumRpcUrl: string;
|
||||
/** DataHaven container name for inter-container networking */
|
||||
datahavenContainerName: string;
|
||||
/** ServiceManager contract address from deployments */
|
||||
serviceManagerAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the validator-set-submitter as a Docker container.
|
||||
*
|
||||
* Generates a YAML config, mounts it into the container, and connects
|
||||
* it to the same Docker network as the DH nodes and relayers.
|
||||
*/
|
||||
export async function launchSubmitter(options: LaunchSubmitterOptions): Promise<{
|
||||
containerName: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const { networkName, networkId, ethereumRpcUrl, datahavenContainerName, serviceManagerAddress } =
|
||||
options;
|
||||
|
||||
const containerName = `submitter-${networkId}`;
|
||||
|
||||
// Extract port from host-facing URL and rewrite for Docker inter-container access
|
||||
const ethUrl = new URL(ethereumRpcUrl);
|
||||
const dockerEthRpcUrl = `http://host.docker.internal:${ethUrl.port}`;
|
||||
const dockerDhWsUrl = `ws://${datahavenContainerName}:9944`;
|
||||
|
||||
// Generate YAML config
|
||||
const configContent = [
|
||||
`ethereum_rpc_url: "${dockerEthRpcUrl}"`,
|
||||
`datahaven_ws_url: "${dockerDhWsUrl}"`,
|
||||
`service_manager_address: "${serviceManagerAddress}"`,
|
||||
`network_id: "anvil"`,
|
||||
`execution_fee: "0.1"`,
|
||||
`relayer_fee: "0.2"`
|
||||
].join("\n");
|
||||
|
||||
const configFileName = `submitter-config-${networkId}.yml`;
|
||||
await $`mkdir -p ${RELAYER_CONFIG_DIR}`.quiet();
|
||||
const hostConfigPath = path.resolve(path.join(RELAYER_CONFIG_DIR, configFileName));
|
||||
await Bun.write(hostConfigPath, configContent);
|
||||
logger.debug(`Submitter config written to ${hostConfigPath}`);
|
||||
|
||||
// Remove any existing container with the same name
|
||||
await $`docker rm -f ${containerName}`.quiet().nothrow();
|
||||
|
||||
// Launch the container
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
containerName,
|
||||
"--network",
|
||||
networkName,
|
||||
"--add-host",
|
||||
"host.docker.internal:host-gateway",
|
||||
"-v",
|
||||
`${hostConfigPath}:/config/config.yml:ro`,
|
||||
"-e",
|
||||
`SUBMITTER_PRIVATE_KEY=${ANVIL_FUNDED_ACCOUNTS[6].privateKey}`,
|
||||
SUBMITTER_IMAGE
|
||||
];
|
||||
|
||||
await $`docker ${args}`.quiet();
|
||||
await waitForContainerToStart(containerName);
|
||||
try {
|
||||
await waitForLog({
|
||||
containerName,
|
||||
search: SUBMITTER_READY_LOG,
|
||||
timeoutSeconds: SUBMITTER_READY_TIMEOUT_SECONDS
|
||||
});
|
||||
} catch (error) {
|
||||
const logs =
|
||||
(await $`docker logs --tail ${SUBMITTER_LOG_TAIL_LINES} ${containerName}`.nothrow().text()) ||
|
||||
"<no logs captured>";
|
||||
await stopSubmitter(containerName);
|
||||
throw new Error(
|
||||
`Submitter did not become ready. Expected log "${SUBMITTER_READY_LOG}". Last ${SUBMITTER_LOG_TAIL_LINES} log lines:\n${logs}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Submitter container ${containerName} started`);
|
||||
|
||||
const cleanup = async () => {
|
||||
await stopSubmitter(containerName);
|
||||
};
|
||||
|
||||
return { containerName, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops and removes the submitter container.
|
||||
*/
|
||||
export async function stopSubmitter(containerName: string): Promise<void> {
|
||||
logger.debug(`Stopping submitter container ${containerName}...`);
|
||||
await $`docker rm -f ${containerName}`.quiet().nothrow();
|
||||
logger.debug(`Submitter container ${containerName} removed`);
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
* - Observe `ExternalValidators.ExternalValidatorsSet` on DataHaven (substrate), confirming propagation.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { getOwnerAccount } from "launcher/validators";
|
||||
import {
|
||||
CROSS_CHAIN_TIMEOUTS,
|
||||
type Deployments,
|
||||
|
|
@ -20,14 +19,15 @@ import {
|
|||
ZERO_ADDRESS
|
||||
} from "utils";
|
||||
import { waitForDataHavenEvent } from "utils/events";
|
||||
import { decodeEventLog, parseEther } from "viem";
|
||||
import { dataHavenServiceManagerAbi, gatewayAbi } from "../../contract-bindings";
|
||||
import { dataHavenServiceManagerAbi } from "../../contract-bindings";
|
||||
import {
|
||||
addValidatorToAllowlist,
|
||||
BaseTestSuite,
|
||||
buildSubmitterImage,
|
||||
getValidator,
|
||||
isValidatorRunning,
|
||||
launchDatahavenValidator,
|
||||
launchSubmitter,
|
||||
registerOperator,
|
||||
type TestConnectors
|
||||
} from "../framework";
|
||||
|
|
@ -50,11 +50,18 @@ class ValidatorSetUpdateTestSuite extends BaseTestSuite {
|
|||
launchDatahavenValidator("charlie", { launchedNetwork }),
|
||||
launchDatahavenValidator("dave", { launchedNetwork })
|
||||
]);
|
||||
|
||||
// Build the submitter Docker image so it's ready for the test
|
||||
await buildSubmitterImage();
|
||||
}
|
||||
|
||||
public getNetworkId(): string {
|
||||
return this.getConnectors().launchedNetwork.networkId;
|
||||
}
|
||||
|
||||
public getLaunchedNetwork() {
|
||||
return this.getConnectors().launchedNetwork;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the test suite instance
|
||||
|
|
@ -173,7 +180,7 @@ describe("Validator Set Update", () => {
|
|||
it(
|
||||
"should send updated validator set and verify on DataHaven",
|
||||
async () => {
|
||||
const { publicClient, walletClient, dhApi } = connectors;
|
||||
const { dhApi } = connectors;
|
||||
|
||||
// Era rotation was paused in beforeAll. Wait for any pending transition to settle
|
||||
// (ForceNone prevents new eras, but an in-progress one must finish first).
|
||||
|
|
@ -190,44 +197,7 @@ describe("Validator Set Update", () => {
|
|||
}
|
||||
|
||||
const targetEra = BigInt(stableEraIndex + 1);
|
||||
|
||||
// Send the updated validator set via Snowbridge
|
||||
const hash = await walletClient.writeContract({
|
||||
address: deployments.ServiceManager as `0x${string}`,
|
||||
abi: dataHavenServiceManagerAbi,
|
||||
functionName: "sendNewValidatorSetForEra",
|
||||
args: [targetEra, parseEther("0.1"), parseEther("0.2")],
|
||||
value: parseEther("0.3"),
|
||||
account: getOwnerAccount(),
|
||||
chain: null
|
||||
});
|
||||
const receipt = await publicClient.waitForTransactionReceipt({ hash });
|
||||
logger.info(
|
||||
`sendNewValidatorSet tx status: ${receipt.status}, block: ${receipt.blockNumber}`
|
||||
);
|
||||
expect(receipt.status).toBe("success");
|
||||
|
||||
// Verify OutboundMessageAccepted event was emitted
|
||||
const hasOutboundAccepted = (receipt.logs ?? []).some((log: any) => {
|
||||
try {
|
||||
const decoded = decodeEventLog({
|
||||
abi: gatewayAbi,
|
||||
data: log.data,
|
||||
topics: log.topics
|
||||
});
|
||||
if (decoded.eventName === "OutboundMessageAccepted") {
|
||||
logger.info(`OutboundMessageAccepted event: nonce=${(decoded.args as any)?.nonce}`);
|
||||
}
|
||||
return decoded.eventName === "OutboundMessageAccepted";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
expect(hasOutboundAccepted).toBe(true);
|
||||
|
||||
logger.info("Waiting for ExternalValidators.ExternalValidatorsSet event on DataHaven...");
|
||||
// Wait for the validator set to be updated on Substrate
|
||||
await waitForDataHavenEvent({
|
||||
const validatorSetUpdated = waitForDataHavenEvent({
|
||||
api: dhApi,
|
||||
pallet: "ExternalValidators",
|
||||
event: "ExternalValidatorsSet",
|
||||
|
|
@ -235,6 +205,27 @@ describe("Validator Set Update", () => {
|
|||
BigInt(event.external_index) === targetEra,
|
||||
timeout: CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
|
||||
});
|
||||
// Prevent unhandled rejection if launchSubmitter fails before we await this promise.
|
||||
void validatorSetUpdated.catch(() => undefined);
|
||||
|
||||
// Launch the submitter daemon — it will detect the last-session condition
|
||||
// and automatically call sendNewValidatorSetForEra on the ServiceManager.
|
||||
const launchedNetwork = suite.getLaunchedNetwork();
|
||||
const { cleanup: cleanupSubmitter } = await launchSubmitter({
|
||||
networkName: launchedNetwork.networkName,
|
||||
networkId: suite.getNetworkId(),
|
||||
ethereumRpcUrl: connectors.elRpcUrl,
|
||||
datahavenContainerName: `datahaven-alice-${suite.getNetworkId()}`,
|
||||
serviceManagerAddress: deployments.ServiceManager
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info("Waiting for ExternalValidators.ExternalValidatorsSet event on DataHaven...");
|
||||
// Wait for the validator set to be updated on Substrate
|
||||
await validatorSetUpdated;
|
||||
} finally {
|
||||
await cleanupSubmitter();
|
||||
}
|
||||
|
||||
// Resume era rotation
|
||||
const resumeTx = dhApi.tx.Sudo.sudo({
|
||||
|
|
|
|||
Loading…
Reference in a new issue