datahaven/test/launcher/relayers.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

712 lines
25 KiB
TypeScript

import path from "node:path";
import { datahaven } from "@polkadot-api/descriptors";
import { $ } from "bun";
import { createClient, type PolkadotClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import invariant from "tiny-invariant";
import {
ANVIL_FUNDED_ACCOUNTS,
DEFAULT_SUBSTRATE_WS_PORT,
getEvmEcdsaSigner,
getPortFromKurtosis,
killExistingContainers,
logger,
parseDeploymentsFile,
parseRelayConfig,
runShellCommandWithLogger,
SUBSTRATE_FUNDED_ACCOUNTS,
waitForContainerToStart
} from "utils";
import type { BeaconCheckpoint, FinalityCheckpointsResponse } from "utils/types";
import { parseJsonToBeaconCheckpoint } from "utils/types";
import { waitFor } from "utils/waits";
import type { LaunchedNetwork } from "./types/launchedNetwork";
import { ZERO_HASH } from "./utils/constants";
// Type definitions
export type BeaconConfig = {
type: "beacon";
ethClEndpoint: string;
substrateWsEndpoint: string;
};
export type BeefyConfig = {
type: "beefy";
ethElRpcEndpoint: string;
substrateWsEndpoint: string;
beefyClientAddress: string;
gatewayAddress: string;
};
export type ExecutionConfig = {
type: "execution";
ethElRpcEndpoint: string;
ethClEndpoint: string;
substrateWsEndpoint: string;
gatewayAddress: string;
};
export type SolochainConfig = {
type: "solochain";
ethElRpcEndpoint: string;
substrateWsEndpoint: string;
beefyClientAddress: string;
gatewayAddress: string;
ethClEndpoint: string;
};
export type RelayerConfigType = BeaconConfig | BeefyConfig | ExecutionConfig | SolochainConfig;
export type RelayerSpec = {
name: string;
configFilePath: string;
templateFilePath?: string;
config: RelayerConfigType;
pk: { ethereum?: string; substrate?: string };
};
// Constants
export const INITIAL_CHECKPOINT_DIR = "tmp/beacon-checkpoint";
export const getInitialCheckpointFile = (networkId: string) =>
`dump-initial-checkpoint-${networkId}.json`;
export const getInitialCheckpointPath = (networkId: string) =>
path.join(INITIAL_CHECKPOINT_DIR, getInitialCheckpointFile(networkId));
/**
* Configuration options for launching Snowbridge relayers.
*/
export interface RelayersOptions {
networkId: string;
relayerImageTag: string;
kurtosisEnclaveName: string;
}
/**
* Configuration paths for different relayer types.
*/
export const RELAYER_CONFIG_DIR = "tmp/configs";
export const RELAYER_CONFIG_PATHS = {
BEACON: path.join(RELAYER_CONFIG_DIR, "beacon-relay.json"),
BEEFY: path.join(RELAYER_CONFIG_DIR, "beefy-relay.json"),
EXECUTION: path.join(RELAYER_CONFIG_DIR, "execution-relay.json"),
SOLOCHAIN: path.join(RELAYER_CONFIG_DIR, "solochain-relay.json")
};
/**
* Generates configuration files for relayers.
*
* @param relayerSpec - The relayer specification containing name, type, and config path.
* @param environment - The environment to use for template files (e.g., "local", "stagenet", "testnet", "mainnet").
* @param configDir - The directory where config files should be written.
*/
export const generateRelayerConfig = async (
relayerSpec: RelayerSpec,
environment: string,
configDir: string
) => {
const { name, configFilePath, templateFilePath: _templateFilePath, config } = relayerSpec;
const { type } = config;
const configFileName = path.basename(configFilePath);
logger.debug(`Creating config for ${name}`);
const templateFilePath =
_templateFilePath ?? `configs/snowbridge/${environment}/${configFileName}`;
const outputFilePath = path.resolve(configDir, configFileName);
logger.debug(`Reading config file ${templateFilePath}`);
const file = Bun.file(templateFilePath);
if (!(await file.exists())) {
logger.error(`File ${templateFilePath} does not exist`);
throw new Error("Error reading snowbridge config file");
}
const json = await file.json();
logger.debug(`Generating ${type} relayer configuration for ${name}`);
switch (type) {
case "beacon": {
const cfg = parseRelayConfig(json, type);
cfg.source.beacon.endpoint = config.ethClEndpoint;
cfg.source.beacon.stateEndpoint = config.ethClEndpoint;
cfg.source.beacon.datastore.location = "/relay-data";
cfg.sink.parachain.endpoint = config.substrateWsEndpoint;
await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4));
logger.success(`Updated beacon config written to ${outputFilePath}`);
break;
}
case "beefy": {
const cfg = parseRelayConfig(json, type);
cfg.source.polkadot.endpoint = config.substrateWsEndpoint;
cfg.sink.ethereum.endpoint = config.ethElRpcEndpoint;
cfg.sink.contracts.BeefyClient = config.beefyClientAddress;
cfg.sink.contracts.Gateway = config.gatewayAddress;
await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4));
logger.success(`Updated beefy config written to ${outputFilePath}`);
break;
}
case "execution": {
const cfg = parseRelayConfig(json, type);
cfg.source.ethereum.endpoint = config.ethElRpcEndpoint;
cfg.source.beacon.endpoint = config.ethClEndpoint;
cfg.source.beacon.stateEndpoint = config.ethClEndpoint;
cfg.source.beacon.datastore.location = "/relay-data";
cfg.sink.parachain.endpoint = config.substrateWsEndpoint;
cfg.source.contracts.Gateway = config.gatewayAddress;
await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4));
logger.success(`Updated execution config written to ${outputFilePath}`);
break;
}
case "solochain": {
const cfg = parseRelayConfig(json, type);
cfg.source.ethereum.endpoint = config.ethElRpcEndpoint;
cfg.source.solochain.endpoint = config.substrateWsEndpoint;
cfg.source.contracts.BeefyClient = config.beefyClientAddress;
cfg.source.contracts.Gateway = config.gatewayAddress;
cfg.source.beacon.endpoint = config.ethClEndpoint;
cfg.source.beacon.stateEndpoint = config.ethClEndpoint;
cfg.source.beacon.datastore.location = "/relay-data";
cfg.sink.ethereum.endpoint = config.ethElRpcEndpoint;
cfg.sink.contracts.Gateway = config.gatewayAddress;
await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4));
logger.success(`Updated solochain config written to ${outputFilePath}`);
break;
}
default:
throw new Error(`Unsupported relayer type with config: \n${JSON.stringify(config)}`);
}
};
/**
* Waits for the beacon chain to be ready by polling its finality checkpoints.
*
* @param launchedNetwork - An instance of LaunchedNetwork to get the CL endpoint.
* @param pollIntervalMs - The interval in milliseconds to poll the beacon chain.
* @param timeoutMs - The total time in milliseconds to wait before timing out.
* @throws Error if the beacon chain is not ready within the timeout.
*/
export const waitBeaconChainReady = async (
launchedNetwork: LaunchedNetwork,
pollIntervalMs: number,
timeoutMs: number
) => {
const iterations = Math.floor(timeoutMs / pollIntervalMs);
logger.trace("Waiting for beacon chain to be ready...");
await waitFor({
lambda: async () => {
try {
const response = await fetch(
`${launchedNetwork.clEndpoint}/eth/v1/beacon/states/head/finality_checkpoints`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = (await response.json()) as FinalityCheckpointsResponse;
logger.debug(`Beacon chain state: ${JSON.stringify(data)}`);
invariant(data.data, "❌ No data returned from beacon chain");
invariant(data.data.finalized, "❌ No finalised block returned from beacon chain");
invariant(
data.data.finalized.root,
"❌ No finalised block root returned from beacon chain"
);
const initialBeaconBlock = data.data.finalized.root;
if (initialBeaconBlock && initialBeaconBlock !== ZERO_HASH) {
logger.info(`⏲️ Beacon chain is ready with finalised block: ${initialBeaconBlock}`);
return true;
}
logger.info(`⌛️ Retrying beacon chain state fetch in ${pollIntervalMs / 1000}s...`);
return false;
} catch (error) {
logger.error(`Failed to fetch beacon chain state: ${error}`);
return false;
}
},
iterations,
delay: pollIntervalMs,
errorMessage: "Beacon chain is not ready. Relayers cannot be launched."
});
};
/**
* Initialises the Ethereum Beacon Client pallet on the Substrate chain.
* It waits for the beacon chain to be ready, generates an initial checkpoint,
* and submits this checkpoint to the Substrate runtime via a sudo call.
*
* @param beaconConfigHostPath - The host path to the beacon configuration file.
* @param relayerImageTag - The Docker image tag for the relayer.
* @param datastorePath - The path to the datastore directory.
* @param launchedNetwork - An instance of LaunchedNetwork to interact with the running network.
* @throws If there's an error generating the beacon checkpoint or submitting it to Substrate.
*/
export const initEthClientPallet = async (
networkId: string,
beaconConfigHostPath: string,
relayerImageTag: string,
datastorePath: string,
launchedNetwork: LaunchedNetwork
) => {
logger.debug("Initialising eth client pallet");
// Poll the beacon chain until it's ready every 10 seconds for 10 minutes
await waitBeaconChainReady(launchedNetwork, 10000, 600000);
const beaconConfigContainerPath = "/app/beacon-relay.json";
const checkpointHostPath = path.resolve(getInitialCheckpointPath(networkId));
const checkpointContainerPath = "/app/dump-initial-checkpoint.json"; // Hardcoded filename that generate-beacon-checkpoint expects
logger.debug("Generating beacon checkpoint");
// Pre-create the checkpoint file so that Docker doesn't interpret it as a directory
await Bun.write(getInitialCheckpointPath(networkId), "");
logger.debug(`Removing 'generate-beacon-checkpoint-${networkId}' container if it exists`);
logger.debug(await $`docker rm -f generate-beacon-checkpoint-${networkId}`.text());
// When running in Linux, `host.docker.internal` is not pre-defined when running in a container.
// So we need to add the parameter `--add-host host.docker.internal:host-gateway` to the command.
// In Mac this is not needed and could cause issues.
const addHostParam =
process.platform === "linux" ? "--add-host host.docker.internal:host-gateway" : "";
// Opportunistic pull - pull the image from Docker Hub only if it's not a local image
const isLocal = relayerImageTag.endsWith(":local");
logger.debug("Generating beacon checkpoint");
const datastoreHostPath = path.resolve(datastorePath);
const command = `docker run \
-v ${beaconConfigHostPath}:${beaconConfigContainerPath}:ro \
-v ${checkpointHostPath}:${checkpointContainerPath} \
-v ${datastoreHostPath}:/data \
--name generate-beacon-checkpoint-${networkId} \
--platform linux/amd64 \
--workdir /app \
${addHostParam} \
${launchedNetwork.networkName ? `--network ${launchedNetwork.networkName}` : ""} \
${isLocal ? "" : "--pull always"} \
${relayerImageTag} \
generate-beacon-checkpoint --config beacon-relay.json --export-json`;
logger.debug(`Running command: ${command}`);
logger.debug(await $`sh -c "${command}"`.text());
// Load the checkpoint into a JSON object and clean it up
const initialCheckpointFile = Bun.file(getInitialCheckpointPath(networkId));
const initialCheckpointRaw = await initialCheckpointFile.text();
const initialCheckpoint = parseJsonToBeaconCheckpoint(JSON.parse(initialCheckpointRaw));
await initialCheckpointFile.delete();
logger.trace("Initial checkpoint:");
logger.trace(initialCheckpoint.toJSON());
// Send the checkpoint to the Substrate runtime
const substrateRpcUrl = `http://127.0.0.1:${launchedNetwork.getPublicWsPort()}`;
await sendCheckpointToSubstrate(substrateRpcUrl, initialCheckpoint);
logger.success("Ethereum Beacon Client pallet initialised");
};
/**
* Sends the beacon checkpoint to the Substrate runtime, waiting for the transaction to be finalised and successful.
*
* @param networkRpcUrl - The RPC URL of the Substrate network.
* @param checkpoint - The beacon checkpoint to send.
* @throws If the transaction signing fails, it becomes an invalid transaction, or the transaction is included but fails.
*/
const sendCheckpointToSubstrate = async (networkRpcUrl: string, checkpoint: BeaconCheckpoint) => {
logger.trace("Sending checkpoint to Substrate...");
const client = createClient(withPolkadotSdkCompat(getWsProvider(networkRpcUrl)));
const dhApi = client.getTypedApi(datahaven);
logger.trace("Client created");
const signer = getEvmEcdsaSigner(SUBSTRATE_FUNDED_ACCOUNTS.ALITH.privateKey);
logger.trace("Signer created");
const forceCheckpointCall = dhApi.tx.EthereumBeaconClient.force_checkpoint({
update: checkpoint
});
logger.debug("Force checkpoint call:");
logger.debug(forceCheckpointCall.decodedCall);
const tx = dhApi.tx.Sudo.sudo({
call: forceCheckpointCall.decodedCall
});
logger.debug("Sudo call:");
logger.debug(tx.decodedCall);
try {
const txFinalisedPayload = await tx.signAndSubmit(signer);
if (!txFinalisedPayload.ok) {
throw new Error("❌ Beacon checkpoint transaction failed");
}
logger.info(
`📪 "force_checkpoint" transaction with hash ${txFinalisedPayload.txHash} submitted successfully and finalised in block ${txFinalisedPayload.block.hash}`
);
} catch (error) {
logger.error(`Failed to submit checkpoint transaction: ${error}`);
throw new Error(`Failed to submit checkpoint: ${error}`);
} finally {
client.destroy();
logger.debug("Destroyed client");
}
};
/**
* Launches Snowbridge relayers for cross-chain communication.
*
* This function sets up and launches all required Snowbridge relayers:
* - BEEFY relayer: Handles BEEFY protocol messages
* - Beacon relayer: Syncs Ethereum beacon chain state
* - Execution relayer: Processes execution layer events
* - Solochain relayer: Handles solochain-specific operations
*
* The function performs the following steps:
* 1. Kills any existing relayer containers
* 2. Waits for BEEFY protocol to be ready
* 3. Retrieves contract addresses from deployments
* 4. Creates configuration directories
* 5. Generates relayer configurations
* 6. Initializes the Ethereum client pallet
* 7. Starts all relayer containers
*
* @param options - Configuration options for launching relayers
* @param options.relayerImageTag - Docker image tag for the relayer containers
* @param options.kurtosisEnclaveName - Name of the Kurtosis enclave for Ethereum services
* @param launchedNetwork - The launched network instance containing connection details
*
* @throws {Error} If the relayer image tag is not provided
* @throws {Error} If BEEFY protocol is not ready within timeout
* @throws {Error} If required contract addresses are not found
* @throws {Error} If Docker operations fail
*/
export const launchRelayers = async (
options: RelayersOptions,
launchedNetwork: LaunchedNetwork
): Promise<void> => {
logger.info("🚀 Launching Snowbridge relayers...");
const { relayerImageTag, kurtosisEnclaveName } = options;
invariant(relayerImageTag, "❌ relayerImageTag is required");
await killExistingContainers("snowbridge-");
// Get DataHaven node port
const dhNodes = launchedNetwork.containers.filter((container) =>
container.name.includes("datahaven")
);
let substrateWsPort: number;
let substrateWsInternalPort: number;
let substrateNodeId: string;
if (dhNodes.length === 0) {
logger.warn(
`⚠️ No DataHaven nodes found in launchedNetwork. Assuming DataHaven is running and defaulting to ${DEFAULT_SUBSTRATE_WS_PORT} for relayers.`
);
substrateWsPort = DEFAULT_SUBSTRATE_WS_PORT;
substrateWsInternalPort = DEFAULT_SUBSTRATE_WS_PORT;
substrateNodeId = "default (assumed)";
} else {
const firstDhNode = dhNodes[0];
substrateWsPort = firstDhNode.publicPorts.ws;
substrateWsInternalPort = firstDhNode.internalPorts.ws;
substrateNodeId = firstDhNode.name;
logger.info(
`🔌 Using DataHaven node ${substrateNodeId} on port ${substrateWsPort} for relayers and BEEFY check.`
);
}
// Check if BEEFY is ready before proceeding
await waitBeefyReady(launchedNetwork, 2000, 60000);
const deployments = await parseDeploymentsFile();
const beefyClientAddress = deployments.BeefyClient;
const gatewayAddress = deployments.Gateway;
invariant(beefyClientAddress, "❌ BeefyClient address not found in anvil.json");
invariant(gatewayAddress, "❌ Gateway address not found in anvil.json");
logger.debug(`Ensuring output directory exists: ${RELAYER_CONFIG_DIR}`);
await $`mkdir -p ${RELAYER_CONFIG_DIR}`.quiet();
const datastorePath = "tmp/datastore";
logger.debug(`Clearing and recreating datastore directory: ${datastorePath}`);
await $`rm -rf ${datastorePath} && mkdir -p ${datastorePath}`.quiet();
const ethWsPort = await getPortFromKurtosis("el-1-reth-lodestar", "ws", kurtosisEnclaveName);
const ethHttpPort = await getPortFromKurtosis("cl-1-lodestar-reth", "http", kurtosisEnclaveName);
const ethElRpcEndpoint = `ws://host.docker.internal:${ethWsPort}`;
const ethClEndpoint = `http://host.docker.internal:${ethHttpPort}`;
const substrateWsEndpoint = `ws://${substrateNodeId}:${substrateWsInternalPort}`;
logger.info(`🔗 Substrate endpoint for relayers: ${substrateWsEndpoint}`);
const relayersToStart: RelayerSpec[] = [
{
name: "relayer-🥩",
configFilePath: RELAYER_CONFIG_PATHS.BEEFY,
config: {
type: "beefy",
ethElRpcEndpoint,
substrateWsEndpoint,
beefyClientAddress,
gatewayAddress
},
pk: {
ethereum: ANVIL_FUNDED_ACCOUNTS[1].privateKey
}
},
{
name: "relayer-🥓",
configFilePath: RELAYER_CONFIG_PATHS.BEACON,
config: {
type: "beacon",
ethClEndpoint,
substrateWsEndpoint
},
pk: {
substrate: SUBSTRATE_FUNDED_ACCOUNTS.BALTATHAR.privateKey
}
},
{
name: "relayer-⛓️",
configFilePath: RELAYER_CONFIG_PATHS.SOLOCHAIN,
config: {
type: "solochain",
ethElRpcEndpoint,
substrateWsEndpoint,
beefyClientAddress,
gatewayAddress,
ethClEndpoint
},
pk: {
ethereum: ANVIL_FUNDED_ACCOUNTS[1].privateKey,
substrate: SUBSTRATE_FUNDED_ACCOUNTS.CHARLETH.privateKey
}
},
{
name: "relayer-⚙️",
configFilePath: RELAYER_CONFIG_PATHS.EXECUTION,
config: {
type: "execution",
ethElRpcEndpoint,
ethClEndpoint,
substrateWsEndpoint,
gatewayAddress
},
pk: {
substrate: SUBSTRATE_FUNDED_ACCOUNTS.DOROTHY.privateKey
}
}
];
// Generate configurations for all relayers
for (const relayerSpec of relayersToStart) {
await generateRelayerConfig(relayerSpec, "local", RELAYER_CONFIG_DIR);
}
invariant(
launchedNetwork.networkName,
"❌ Docker network name not found in LaunchedNetwork instance"
);
// Initialize Ethereum client pallet
await initEthClientPallet(
options.networkId,
path.resolve(RELAYER_CONFIG_PATHS.BEACON),
relayerImageTag,
datastorePath,
launchedNetwork
);
// Launch all relayers
await launchRelayerContainers(
relayersToStart,
relayerImageTag,
launchedNetwork,
options.networkId
);
logger.success("Snowbridge relayers launched successfully");
};
/**
* Waits for the BEEFY protocol to be ready by polling its finalized head.
*
* @param launchedNetwork - An instance of LaunchedNetwork to get the node endpoint
* @param pollIntervalMs - The interval in milliseconds to poll the BEEFY endpoint
* @param timeoutMs - The total time in milliseconds to wait before timing out
*
* @throws {Error} If BEEFY is not ready within the timeout
*/
const waitBeefyReady = async (
launchedNetwork: LaunchedNetwork,
pollIntervalMs: number,
timeoutMs: number
): Promise<void> => {
const port = launchedNetwork.getPublicWsPort();
const wsUrl = `ws://127.0.0.1:${port}`;
const iterations = Math.floor(timeoutMs / pollIntervalMs);
logger.info(`⌛️ Waiting for BEEFY to be ready on port ${port}...`);
let client: PolkadotClient | undefined;
const clientTimeoutMs = pollIntervalMs / 2;
const delayMs = pollIntervalMs / 2;
try {
client = createClient(withPolkadotSdkCompat(getWsProvider(wsUrl)));
await waitFor({
lambda: async () => {
try {
logger.debug("Attempting to to check beefy_getFinalizedHead");
// Add timeout to the RPC call to prevent hanging.
const finalisedHeadPromise = client?._request<string>("beefy_getFinalizedHead", []);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("RPC call timeout")), clientTimeoutMs);
});
const finalisedHeadHex = await Promise.race([finalisedHeadPromise, timeoutPromise]);
if (finalisedHeadHex && finalisedHeadHex !== ZERO_HASH) {
logger.info(`🥩 BEEFY is ready. Finalised head: ${finalisedHeadHex}.`);
return true;
}
logger.debug(
`BEEFY not ready or finalised head is zero. Retrying in ${delayMs / 1000}s...`
);
return false;
} catch (rpcError) {
logger.warn(`RPC error checking BEEFY status: ${rpcError}. Retrying...`);
return false;
}
},
iterations,
delay: delayMs,
errorMessage: "BEEFY protocol not ready. Relayers cannot be launched."
});
} catch (error) {
logger.error(`❌ Failed to connect to DataHaven node for BEEFY check: ${error}`);
throw new Error("BEEFY protocol not ready. Relayers cannot be launched.");
} finally {
if (client) {
client.destroy();
}
}
};
/**
* Launches individual relayer containers.
*
* @param relayersToStart - Array of relayer specifications
* @param relayerImageTag - Docker image tag for the relayers
* @param launchedNetwork - The launched network instance
* @param networkId - The network ID to suffix container names
*/
const launchRelayerContainers = async (
relayersToStart: RelayerSpec[],
relayerImageTag: string,
launchedNetwork: LaunchedNetwork,
networkId: string
): Promise<void> => {
const isLocal = relayerImageTag.endsWith(":local");
const networkName = launchedNetwork.networkName;
invariant(networkName, "❌ Docker network name not found in LaunchedNetwork instance");
const restartArgs = ["--restart", "on-failure:5"];
logger.info(`🔁 Relayer restart policy enabled: ${restartArgs.join(" ")}`);
for (const { configFilePath, name, config, pk } of relayersToStart) {
try {
const containerName = `snowbridge-${config.type}-relay-${networkId}`;
logger.info(`🚀 Starting relayer ${containerName} ...`);
const hostConfigFilePath = path.resolve(configFilePath);
const containerConfigFilePath = `/${configFilePath}`;
const commandBase: string[] = [
"docker",
"run",
"-d",
"--platform",
"linux/amd64",
"--add-host",
"host.docker.internal:host-gateway",
"--name",
containerName,
"--network",
networkName,
...restartArgs,
...(isLocal ? [] : ["--pull", "always"])
];
const volumeMounts: string[] = ["-v", `${hostConfigFilePath}:${containerConfigFilePath}`];
if (config.type === "beacon" || config.type === "execution") {
const hostDatastorePath = path.resolve("tmp/datastore");
const containerDatastorePath = "/relay-data";
volumeMounts.push("-v", `${hostDatastorePath}:${containerDatastorePath}`);
}
const relayerCommandArgs: string[] = ["run", config.type, "--config", configFilePath];
switch (config.type) {
case "beacon":
invariant(pk.substrate, "❌ Substrate private key is required for beacon relayer");
relayerCommandArgs.push("--substrate.private-key", pk.substrate);
break;
case "beefy":
invariant(pk.ethereum, "❌ Ethereum private key is required for beefy relayer");
relayerCommandArgs.push("--ethereum.private-key", pk.ethereum);
break;
case "solochain":
invariant(pk.ethereum, "❌ Ethereum private key is required for solochain relayer");
relayerCommandArgs.push("--ethereum.private-key", pk.ethereum);
if (pk.substrate) {
relayerCommandArgs.push("--substrate.private-key", pk.substrate);
} else {
logger.warn(
"⚠️ No substrate private key provided for solochain relayer. This might be an issue depending on the configuration."
);
}
break;
case "execution":
invariant(pk.substrate, "❌ Substrate private key is required for execution relayer");
relayerCommandArgs.push("--substrate.private-key", pk.substrate);
break;
}
const command: string[] = [
...commandBase,
...volumeMounts,
relayerImageTag,
...relayerCommandArgs
];
logger.debug(`Running command: ${command.join(" ")}`);
await runShellCommandWithLogger(command.join(" "), { logLevel: "debug" });
launchedNetwork.addContainer(containerName);
await waitForContainerToStart(containerName);
logger.success(`Started relayer ${name} with process ${process.pid}`);
} catch (e) {
logger.error(`Error starting relayer ${name}`);
logger.error(e);
}
}
};