mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
## Summary This PR introduces support for deploying Datahaven contracts to different chains (hoodi, holesky, mainnet), as well as a new cli command to manage this deployment separately from the regular deployment, while maintaining compatibility with it. #### New CLI command - **`bun cli contracts deploy`** - Deploy contracts to supported chains (Hoodi, Holesky, Mainnet) - **`bun cli contracts status`** - Check deployment configuration and status - **`bun cli contracts verify`** - Verify contracts on block explorers - Commands need the chain parameter: `--chain <hoodi | holesky | mainnet>` - Right now only `hoodi` and `holesky` are supported ### Deployment #### Hoodi & Holesky Network Support - Added **DeployBase.s.sol** as common ground for **DeployTestnet.s.sol** (also new) and **DeployLocal.s.sol** (existing). - **Hoodi configuration** (`contracts/config/hoodi.json`) with deployed EigenLayer contract addresses to reference. - **Holesky configuration** (`contracts/config/hoodi.json`) with deployed EigenLayer contract addresses to reference. #### Contracts being deployed - **DataHaven**: ServiceManager, VetoableSlasher, RewardsRegistry - **Snowbridge**: BeefyClient, AgentExecutor, Gateway, RewardsAgent - **EigenLayer**: References existing deployed contracts (not re-deployed) #### Deployment files When the deployment is done, a new file under `contracts/deployments` is generated with the addresses of the deployed contracts, for each chain (it will be overriden per chain if run multiple times). So we would have one `anvil.json`, `hoodi.json`, `holesky.json`, etc, with the addresses of the deployed contracts for reference and for later verification. #### Todo - [x] Test compatibility with existing `bun cli launch` and `bun cli deploy` commands #### For follow-up PRs - Fix verification issue with `foundry verify-contracts` when specifying the `chain` or `chain-id` parameter, needed for hoodi (https://github.com/foundry-rs/foundry/issues/7466). - Add `redeploy` feature to only override implementation contract and leave the proxy address untouched ## Usage Examples ```bash # Deploy to Hoodi network bun cli contracts deploy --chain hoodi # Check deployment status bun cli contracts status --chain hoodi # Verify contracts on block explorer bun cli contracts verify --chain hoodi ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added deployment and configuration support for new networks "hoodi" and "holesky", including new configuration and deployment files. * Introduced a CLI tool for managing contract deployments, status checks, and verification across supported chains. * Added example environment configuration and comprehensive deployment documentation. * Enabled contract verification and status reporting via the CLI with support for block explorer integration. * **Improvements** * Refactored deployment scripts for modularity, supporting both local and testnet environments. * Centralized and extended configuration loading to support additional contract addresses and network parameters. * Enhanced deployment utilities and typings to support multi-network deployments. * **Bug Fixes** * Improved input validation and error handling in CLI commands and deployment scripts. * Added explicit handling for zero address in operator strategy retrieval. * **Chores** * Updated documentation and configuration templates for easier onboarding and deployment management. * Improved logging and output formatting for deployment and verification processes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com> Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com>
714 lines
26 KiB
TypeScript
714 lines
26 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/web";
|
|
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;
|
|
rewardsRegistryAddress: 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;
|
|
cfg["reward-address"] = config.rewardsRegistryAddress;
|
|
|
|
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;
|
|
const rewardsRegistryAddress = deployments.RewardsRegistry;
|
|
invariant(beefyClientAddress, "❌ BeefyClient address not found in anvil.json");
|
|
invariant(gatewayAddress, "❌ Gateway address not found in anvil.json");
|
|
invariant(rewardsRegistryAddress, "❌ RewardsRegistry 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(`Ensuring datastore directory exists: ${datastorePath}`);
|
|
await $`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,
|
|
rewardsRegistryAddress,
|
|
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");
|
|
|
|
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,
|
|
...(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);
|
|
}
|
|
}
|
|
};
|