diff --git a/test/e2e/framework/index.ts b/test/e2e/framework/index.ts index c76b4215..da11bf24 100644 --- a/test/e2e/framework/index.ts +++ b/test/e2e/framework/index.ts @@ -1,4 +1,5 @@ export * from "./connectors"; export * from "./manager"; +export * from "./submitter"; export * from "./suite"; export * from "./validators"; diff --git a/test/e2e/framework/submitter.ts b/test/e2e/framework/submitter.ts new file mode 100644 index 00000000..cad168eb --- /dev/null +++ b/test/e2e/framework/submitter.ts @@ -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 { + 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; +}> { + 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()) || + ""; + 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 { + logger.debug(`Stopping submitter container ${containerName}...`); + await $`docker rm -f ${containerName}`.quiet().nothrow(); + logger.debug(`Submitter container ${containerName} removed`); +} diff --git a/test/e2e/suites/validator-set-update.test.ts b/test/e2e/suites/validator-set-update.test.ts index fe18b4cb..c5a665c4 100644 --- a/test/e2e/suites/validator-set-update.test.ts +++ b/test/e2e/suites/validator-set-update.test.ts @@ -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({