test: 🐳 Add Docker relay support to CLI (#74)

## Changes

 - Latest changes to have working relayer 🎉 component
 - Changed spawning snowbridge relayers to docker containers
 - Small logging output changes
 - Refactoring to `LaunchedNetwork` class
- new flag `--bd` `--build-datahaven` which will build a local docker
container which is **much** quicker than the proper CI build (which uses
a controlled build enviroment)
- new bun script `start:e2e:local`, which is everything that
`start:e2e:ci` has, but with building local docker container and
log_level debug set

---
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Added support for launching and managing relayer and DataHaven
services using Docker containers and networks.
- Introduced a CLI option to specify the relayer Docker image tag
instead of a binary path.

- **Improvements**
- Enhanced log messages with clearer text and expressive emojis for
better user feedback.
- Improved summary display by removing relayer services from the output.
- Updated build scripts to consistently enable the "fast-runtime"
feature for cross-platform builds.
  - Refined validation and error reporting for checkpoint data parsing.

- **Bug Fixes**
- Improved Docker container cleanup and network management during
service launch and teardown.

- **Chores**
- Updated and refactored npm scripts for Docker operations and
end-to-end test cleanup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Facundo Farall <37149322+ffarall@users.noreply.github.com>
This commit is contained in:
Tim B 2025-05-19 00:31:46 +01:00 committed by GitHub
parent a86791ec1c
commit 431d1f7181
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 304 additions and 222 deletions

View file

@ -8,13 +8,21 @@ import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { cargoCrossbuild } from "scripts/cargo-crossbuild";
import invariant from "tiny-invariant";
import { waitForContainerToStart } from "utils";
import { confirmWithTimeout, logger, printDivider, printHeader } from "utils";
import {
confirmWithTimeout,
killExistingContainers,
logger,
printDivider,
printHeader,
waitForContainerToStart
} from "utils";
import { type Hex, keccak256, toHex } from "viem";
import { publicKeyToAddress } from "viem/accounts";
import type { LaunchOptions } from ".";
import type { LaunchedNetwork } from "./launchedNetwork";
const DOCKER_NETWORK_NAME = "datahaven-net";
const LOG_LEVEL = Bun.env.LOG_LEVEL || "info";
const COMMON_LAUNCH_ARGS = [
@ -75,8 +83,7 @@ export const launchDataHavenSolochain = async (
}
if (options.datahaven === true) {
logger.info("Proceeding to clean and relaunch DataHaven containers...");
await cleanDataHavenContainers();
await cleanDataHavenContainers(options);
} else {
const shouldRelaunch = await confirmWithTimeout(
"Do you want to clean and relaunch the DataHaven containers?",
@ -91,8 +98,7 @@ export const launchDataHavenSolochain = async (
printDivider();
return;
}
logger.info("Proceeding to clean and relaunch DataHaven containers...");
await cleanDataHavenContainers();
await cleanDataHavenContainers(options);
}
}
@ -104,7 +110,7 @@ export const launchDataHavenSolochain = async (
);
} else {
logger.info(
`Using flag option: ${shouldLaunchDataHaven ? "will launch" : "will not launch"} DataHaven network`
`🏳️ Using flag option: ${shouldLaunchDataHaven ? "will launch" : "will not launch"} DataHaven network`
);
}
@ -114,13 +120,20 @@ export const launchDataHavenSolochain = async (
return;
}
logger.info(`⛓️‍💥 Creating Docker network: ${DOCKER_NETWORK_NAME}`);
logger.debug(await $`docker network rm ${DOCKER_NETWORK_NAME} -f`.text());
logger.debug(await $`docker network create ${DOCKER_NETWORK_NAME}`.text());
invariant(options.datahavenImageTag, "❌ DataHaven image tag not defined");
await buildLocalImage(options);
await checkTagExists(options.datahavenImageTag);
launchedNetwork.networkName = DOCKER_NETWORK_NAME;
logger.success(`DataHaven nodes will use Docker network: ${DOCKER_NETWORK_NAME}`);
for (const id of CLI_AUTHORITY_IDS) {
logger.info(`Starting ${id}...`);
logger.info(`🚀 Starting ${id}...`);
const containerName = `datahaven-${id}`;
const command: string[] = [
@ -129,13 +142,15 @@ export const launchDataHavenSolochain = async (
"-d",
"--name",
containerName,
"--network",
DOCKER_NETWORK_NAME,
...(id === "alice" ? ["-p", `${DEFAULT_PUBLIC_WS_PORT}:9944`] : []),
options.datahavenImageTag,
`--${id}`,
...COMMON_LAUNCH_ARGS
];
logger.debug($`sh -c "${command.join(" ")}"`.text());
logger.debug(await $`sh -c "${command.join(" ")}"`.text());
await waitForContainerToStart(containerName);
@ -151,7 +166,7 @@ export const launchDataHavenSolochain = async (
}
for (let i = 0; i < 30; i++) {
logger.info("Waiting for datahaven to start...");
logger.info("⌛️ Waiting for datahaven to start...");
if (await isNetworkReady(DEFAULT_PUBLIC_WS_PORT)) {
logger.success(
`DataHaven network started, primary node accessible on port ${DEFAULT_PUBLIC_WS_PORT}`
@ -160,7 +175,7 @@ export const launchDataHavenSolochain = async (
await registerNodes(launchedNetwork);
// Call setupDataHavenValidatorConfig now that nodes are up
logger.info("Proceeding with DataHaven validator configuration setup...");
logger.info("🔧 Proceeding with DataHaven validator configuration setup...");
await setupDataHavenValidatorConfig(launchedNetwork);
printDivider();
@ -180,29 +195,42 @@ export const launchDataHavenSolochain = async (
*/
const checkDataHavenRunning = async (): Promise<boolean> => {
// Check for any container whose name starts with "datahaven-"
const PIDS = await $`docker ps -q --filter "name=^datahaven-"`.text();
return PIDS.trim().length > 0;
const containerIds = await $`docker ps -q --filter "name=^datahaven-"`.text();
const networkOutput =
await $`docker network ls --filter "name=^${DOCKER_NETWORK_NAME}$" --format "{{.Name}}"`.text();
// Check if containerIds has any actual IDs (not just whitespace)
const containersExist = containerIds.trim().length > 0;
// Check if networkOutput has any network names (not just whitespace or empty lines)
const networksExist =
networkOutput
.trim()
.split("\n")
.filter((line) => line.trim().length > 0).length > 0;
return containersExist || networksExist;
};
/**
* Stops and removes all DataHaven containers.
*/
const cleanDataHavenContainers = async (): Promise<void> => {
const cleanDataHavenContainers = async (options: LaunchOptions): Promise<void> => {
logger.info("🧹 Stopping and removing existing DataHaven containers...");
const containerIds = (await $`docker ps -a -q --filter "name=^datahaven-"`.text()).trim();
logger.debug(`Container IDs: ${containerIds}`);
if (containerIds.length > 0) {
const idsArray = containerIds
.split("\n")
.map((id) => id.trim())
.filter((id) => id.length > 0);
for (const id of idsArray) {
logger.debug(`Stopping container ${id}`);
logger.debug(await $`docker stop ${id}`.nothrow().text());
logger.debug(await $`docker rm ${id}`.nothrow().text());
}
invariant(options.datahavenImageTag, "❌ DataHaven image tag not defined");
await killExistingContainers(options.datahavenImageTag);
if (options.relayerImageTag) {
logger.info(
"🧹 Stopping and removing existing relayer containers (relayers depend on DataHaven nodes)..."
);
await killExistingContainers(options.relayerImageTag);
}
logger.info("✅ Existing DataHaven containers stopped and removed.");
logger.debug(await $`docker network rm -f ${DOCKER_NETWORK_NAME}`.text());
logger.info("✅ DataHaven Docker network removed.");
};
/**
@ -241,12 +269,12 @@ const buildLocalImage = async (options: LaunchOptions) => {
);
} else {
logger.info(
`Using flag option: ${shouldBuildDataHaven ? "will build" : "will not build"} DataHaven node local Docker image`
`🏳️ Using flag option: ${shouldBuildDataHaven ? "will build" : "will not build"} DataHaven node local Docker image`
);
}
if (!shouldBuildDataHaven) {
logger.info("Skipping DataHaven node local Docker image build. Done!");
logger.info("👍 Skipping DataHaven node local Docker image build. Done!");
return;
}
@ -302,11 +330,11 @@ const registerNodes = async (launchedNetwork: LaunchedNetwork) => {
// If the Docker container is running, proceed to register it in launchedNetwork.
// We use the standard host WS port that "datahaven-alice" is expected to use.
logger.info(
`Docker container ${targetContainerName} is running. Registering with WS port ${aliceHostWsPort}.`
logger.debug(
`Docker container ${targetContainerName} is running. Registering with WS port ${aliceHostWsPort}.`
);
launchedNetwork.addContainer(targetContainerName, { ws: aliceHostWsPort });
logger.success(`👍 Node ${targetContainerName} successfully registered in launchedNetwork.`);
logger.info(`📝 Node ${targetContainerName} successfully registered in launchedNetwork.`);
};
// Function to convert compressed public key to Ethereum address

View file

@ -18,7 +18,7 @@ export interface LaunchOptions {
updateValidatorSet?: boolean;
blockscout?: boolean;
relayer?: boolean;
relayerBinPath?: string;
relayerImageTag?: string;
skipCleaning?: boolean;
alwaysClean?: boolean;
datahaven?: boolean;
@ -75,7 +75,7 @@ const launchFunction = async (options: LaunchOptions, launchedNetwork: LaunchedN
await launchRelayers(options, launchedNetwork);
performSummaryOperations(options, launchedNetwork);
await performSummaryOperations(options, launchedNetwork);
const fullEnd = performance.now();
const fullMinutes = ((fullEnd - timeStart) / (1000 * 60)).toFixed(1);
logger.success(`Launch function completed successfully in ${fullMinutes} minutes`);

View file

@ -22,16 +22,14 @@ export const launchKurtosis = async (
logger.trace("Checking if launchKurtosis option was set via flags");
if (options.launchKurtosis === false) {
logger.info("Keeping existing Kurtosis enclave.");
logger.info("👍 Keeping existing Kurtosis enclave.");
await registerServices(launchedNetwork);
printDivider();
return;
}
if (options.launchKurtosis === true) {
logger.info("Proceeding to clean and relaunch the Kurtosis enclave...");
} else {
if (options.launchKurtosis !== true) {
const shouldRelaunch = await confirmWithTimeout(
"Do you want to clean and relaunch the Kurtosis enclave?",
true,
@ -39,14 +37,12 @@ export const launchKurtosis = async (
);
if (!shouldRelaunch) {
logger.info("Keeping existing Kurtosis enclave.");
logger.info("👍 Keeping existing Kurtosis enclave.");
await registerServices(launchedNetwork);
printDivider();
return;
}
logger.info("Proceeding to clean and relaunch the Kurtosis enclave...");
}
}
@ -142,14 +138,14 @@ const modifyConfig = async (options: LaunchOptions, configFile: string) => {
* @param launchedNetwork - The LaunchedNetwork instance to store network details.
*/
const registerServices = async (launchedNetwork: LaunchedNetwork) => {
logger.info("⚙️ Registering Kurtosis service endpoints...");
logger.info("📝 Registering Kurtosis service endpoints...");
// Configure EL RPC URL
const rethPublicPort = await getPortFromKurtosis("el-1-reth-lighthouse", "rpc");
invariant(rethPublicPort && rethPublicPort > 0, "❌ Could not find EL RPC port");
const elRpcUrl = `http://127.0.0.1:${rethPublicPort}`;
launchedNetwork.elRpcUrl = elRpcUrl;
logger.info(`👍 Execution Layer RPC URL configured: ${elRpcUrl}`);
logger.info(`📝 Execution Layer RPC URL configured: ${elRpcUrl}`);
// Configure CL Endpoint
const lighthousePublicPort = await getPortFromKurtosis("cl-1-lighthouse-reth", "http");
@ -159,5 +155,5 @@ const registerServices = async (launchedNetwork: LaunchedNetwork) => {
"❌ CL Endpoint could not be determined from Kurtosis service cl-1-lighthouse-reth"
);
launchedNetwork.clEndpoint = clEndpoint;
logger.info(`👍 Consensus Layer Endpoint configured: ${clEndpoint}`);
logger.info(`📝 Consensus Layer Endpoint configured: ${clEndpoint}`);
};

View file

@ -15,6 +15,7 @@ export class LaunchedNetwork {
protected processes: BunProcess[];
protected _containers: ContainerSpec[];
protected fileDescriptors: number[];
protected _networkName: string;
protected _activeRelayers: RelayerType[];
/** The RPC URL for the Ethereum Execution Layer (EL) client. */
protected _elRpcUrl?: string;
@ -27,10 +28,20 @@ export class LaunchedNetwork {
this.fileDescriptors = [];
this._containers = [];
this._activeRelayers = [];
this._networkName = "";
this._elRpcUrl = undefined;
this._clEndpoint = undefined;
}
public set networkName(name: string) {
invariant(name.trim().length > 0, "❌ networkName cannot be empty");
this._networkName = name.trim();
}
public get networkName(): string {
return this._networkName;
}
/**
* Gets the unique ID for this run of the launched network.
* @returns The run ID string.
@ -73,12 +84,6 @@ export class LaunchedNetwork {
this._containers.push({ name: containerName, publicPorts });
}
registerRelayerType(type: RelayerType): void {
if (!this._activeRelayers.includes(type)) {
this._activeRelayers.push(type);
}
}
public getPublicWsPort(): number {
logger.debug("Getting public WebSocket port for LaunchedNetwork");
logger.debug("Containers:");
@ -88,13 +93,6 @@ export class LaunchedNetwork {
return port;
}
public get containers(): ContainerSpec[] {
return this._containers;
}
public get relayers(): RelayerType[] {
return [...this._activeRelayers];
}
/**
* Sets the RPC URL for the Ethereum Execution Layer (EL) client.
* @param url - The EL RPC URL string.
@ -131,6 +129,20 @@ export class LaunchedNetwork {
return this._clEndpoint;
}
registerRelayerType(type: RelayerType): void {
if (!this._activeRelayers.includes(type)) {
this._activeRelayers.push(type);
}
}
public get containers(): ContainerSpec[] {
return this._containers;
}
public get relayers(): RelayerType[] {
return [...this._activeRelayers];
}
async cleanup() {
logger.debug("Running cleanup");
for (const process of this.processes) {

View file

@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import { datahaven } from "@polkadot-api/descriptors";
import { $ } from "bun";
@ -13,11 +12,14 @@ import {
confirmWithTimeout,
getEvmEcdsaSigner,
getPortFromKurtosis,
killExistingContainers,
logger,
parseDeploymentsFile,
parseRelayConfig,
printDivider,
printHeader
printHeader,
runShellCommandWithLogger,
waitForContainerToStart
} from "utils";
import type { BeaconCheckpoint, FinalityCheckpointsResponse } from "utils/types";
import { parseJsonToBeaconCheckpoint } from "utils/types";
@ -38,7 +40,9 @@ const RELAYER_CONFIG_PATHS = {
BEACON: path.join(RELAYER_CONFIG_DIR, "beacon-relay.json"),
BEEFY: path.join(RELAYER_CONFIG_DIR, "beefy-relay.json")
};
const INITIAL_CHECKPOINT_PATH = "./dump-initial-checkpoint.json";
const INITIAL_CHECKPOINT_FILE = "dump-initial-checkpoint.json";
const INITIAL_CHECKPOINT_DIR = "tmp/beacon-checkpoint";
const INITIAL_CHECKPOINT_PATH = path.join(INITIAL_CHECKPOINT_DIR, INITIAL_CHECKPOINT_FILE);
/**
* Launches Snowbridge relayers for the DataHaven network.
@ -58,7 +62,7 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
);
} else {
logger.info(
`Using flag option: ${shouldLaunchRelayers ? "will launch" : "will not launch"} Snowbridge relayers`
`🏳️ Using flag option: ${shouldLaunchRelayers ? "will launch" : "will not launch"} Snowbridge relayers`
);
}
@ -90,8 +94,8 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
);
}
// Kill any pre-existing relayer processes if they exist
await $`pkill snowbridge-relay`.nothrow().quiet();
invariant(options.relayerImageTag, "❌ relayerImageTag is required");
await killExistingContainers(options.relayerImageTag);
// Check if BEEFY is ready before proceeding
await waitBeefyReady(launchedNetwork, 2000, 60000);
@ -109,10 +113,6 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
logger.debug(`Ensuring datastore directory exists: ${datastorePath}`);
await $`mkdir -p ${datastorePath}`.quiet();
const logsPath = `tmp/logs/${launchedNetwork.getRunId()}/`;
logger.debug(`Ensuring logs directory exists: ${logsPath}`);
await $`mkdir -p ${logsPath}`.quiet();
const relayersToStart: RelayerSpec[] = [
{
name: "relayer-🥩",
@ -139,7 +139,7 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
logger.debug(`Creating config for ${name}`);
const templateFilePath = `configs/snowbridge/${configFileName}`;
const outputFilePath = `tmp/configs/${configFileName}`;
const outputFilePath = path.resolve(RELAYER_CONFIG_DIR, configFileName);
logger.debug(`Reading config file ${templateFilePath}`);
const file = Bun.file(templateFilePath);
@ -157,18 +157,17 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
if (type === "beacon") {
const cfg = parseRelayConfig(json, type);
cfg.source.beacon.endpoint = `http://127.0.0.1:${ethHttpPort}`;
cfg.source.beacon.stateEndpoint = `http://127.0.0.1:${ethHttpPort}`;
cfg.source.beacon.endpoint = `http://host.docker.internal:${ethHttpPort}`;
cfg.source.beacon.stateEndpoint = `http://host.docker.internal:${ethHttpPort}`;
cfg.source.beacon.datastore.location = "/data";
cfg.sink.parachain.endpoint = `ws://${substrateNodeId}:${substrateWsPort}`;
cfg.source.beacon.datastore.location = datastorePath;
cfg.sink.parachain.endpoint = `ws://127.0.0.1:${substrateWsPort}`;
await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4));
logger.success(`Updated beacon config written to ${outputFilePath}`);
} else {
const cfg = parseRelayConfig(json, type);
cfg.source.polkadot.endpoint = `ws://127.0.0.1:${substrateWsPort}`;
cfg.sink.ethereum.endpoint = `ws://127.0.0.1:${ethWsPort}`;
cfg.source.polkadot.endpoint = `ws://${substrateNodeId}:${substrateWsPort}`;
cfg.sink.ethereum.endpoint = `ws://host.docker.internal:${ethWsPort}`;
cfg.sink.contracts.BeefyClient = beefyClientAddress;
cfg.sink.contracts.Gateway = gatewayAddress;
await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4));
@ -176,27 +175,43 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
}
}
logger.info("Spawning Snowbridge relayers processes");
invariant(options.relayerBinPath, "❌ Relayer binary path not defined");
invariant(
await Bun.file(options.relayerBinPath).exists(),
`❌ Relayer binary does not exist at ${options.relayerBinPath}`
);
invariant(options.relayerImageTag, "❌ Relayer image tag not defined");
await initEthClientPallet(options, launchedNetwork);
for (const { config, name, type, pk } of relayersToStart) {
try {
logger.info(`Starting relayer ${name} ...`);
const logFileName = `${type}-${name.replace(/[^a-zA-Z0-9-]/g, "")}.log`;
const logFilePath = path.join(logsPath, logFileName);
logger.debug(`Writing logs to ${logFilePath}`);
const containerName = `snowbridge-${type}-relay`;
logger.info(`🚀 Starting relayer ${containerName} ...`);
const fd = fs.openSync(logFilePath, "a");
const hostConfigFilePath = path.resolve(config);
const containerConfigFilePath = `/${config}`;
const networkName = launchedNetwork.networkName;
invariant(networkName, "❌ Docker network name not found in LaunchedNetwork instance");
const spawnCommand = [
options.relayerBinPath,
const commandBase: string[] = [
"docker",
"run",
"-d",
"--platform",
"linux/amd64",
"--add-host",
"host.docker.internal:host-gateway",
"--name",
containerName,
"--network",
networkName
];
const volumeMounts: string[] = ["-v", `${hostConfigFilePath}:${containerConfigFilePath}`];
if (type === "beacon") {
const hostDatastorePath = path.resolve(datastorePath);
const containerDatastorePath = "/data";
volumeMounts.push("-v", `${hostDatastorePath}:${containerDatastorePath}`);
}
const relayerCommandArgs: string[] = [
"run",
type,
"--config",
@ -205,17 +220,28 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
pk.value
];
logger.debug(`Spawning command: ${spawnCommand.join(" ")}`);
const command: string[] = [
...commandBase,
...volumeMounts,
options.relayerImageTag,
...relayerCommandArgs
];
const process = Bun.spawn(spawnCommand, {
stdout: fd,
stderr: fd
});
logger.debug(`Running command: ${command.join(" ")}`);
await runShellCommandWithLogger(command.join(" "), { logLevel: "debug" });
process.unref();
launchedNetwork.addContainer(containerName);
await waitForContainerToStart(containerName);
// TODO: Re-enable when we know what we want to tail for
// await waitForLog({
// searchString: "<LOG LINE TO WAIT FOR>",
// containerName,
// timeoutSeconds: 30,
// tail: 1
// });
launchedNetwork.addFileDescriptor(fd);
launchedNetwork.addProcess(process);
logger.debug(`Started relayer ${name} with process ${process.pid}`);
} catch (e) {
logger.error(`Error starting relayer ${name}`);
@ -244,7 +270,7 @@ const waitBeefyReady = async (
const wsUrl = `ws://127.0.0.1:${port}`;
const maxAttempts = Math.floor(timeoutMs / pollIntervalMs);
logger.info(`Waiting for BEEFY to be ready on port ${port}...`);
logger.info(`⌛️ Waiting for BEEFY to be ready on port ${port}...`);
let client: PolkadotClient | undefined;
try {
@ -256,7 +282,7 @@ const waitBeefyReady = async (
const finalizedHeadHex = await client._request<string>("beefy_getFinalizedHead", []);
if (finalizedHeadHex && finalizedHeadHex !== ZERO_HASH) {
logger.success(`🥩 BEEFY is ready. Finalized head: ${finalizedHeadHex}`);
logger.info(`🥩 BEEFY is ready. Finalized head: ${finalizedHeadHex}`);
client.destroy();
return;
}
@ -296,24 +322,40 @@ export const initEthClientPallet = async (
options: LaunchOptions,
launchedNetwork: LaunchedNetwork
) => {
logger.debug("Initialising eth client pallet");
// Poll the beacon chain until it's ready every 10 seconds for 5 minutes
await waitBeaconChainReady(launchedNetwork, 10000, 300000);
// Generate the initial checkpoint for the CL client in Substrate
const { stdout, stderr, exitCode } =
await $`${options.relayerBinPath} generate-beacon-checkpoint --config ${RELAYER_CONFIG_PATHS.BEACON} --export-json`
.nothrow()
.quiet();
if (exitCode !== 0) {
logger.error(stderr);
throw new Error("Error generating beacon checkpoint");
}
logger.trace(`Beacon checkpoint stdout: ${stdout}`);
const beaconConfigHostPath = path.resolve(RELAYER_CONFIG_PATHS.BEACON);
const beaconConfigContainerPath = `/app/${RELAYER_CONFIG_PATHS.BEACON}`;
const checkpointHostPath = path.resolve(INITIAL_CHECKPOINT_PATH);
const checkpointContainerPath = `/app/${INITIAL_CHECKPOINT_FILE}`;
logger.debug("Generating beacon checkpoint");
// Pre-create the checkpoint file so that Docker doesn't interpret it as a directory
await Bun.write(INITIAL_CHECKPOINT_PATH, "");
logger.debug("Removing 'generate-beacon-checkpoint' container if it exists");
logger.debug(await $`docker rm -f generate-beacon-checkpoint`.text());
logger.debug("Generating beacon checkpoint");
const command = `docker run \
-v ${beaconConfigHostPath}:${beaconConfigContainerPath}:ro \
-v ${checkpointHostPath}:${checkpointContainerPath} \
--name generate-beacon-checkpoint \
--workdir /app \
--add-host host.docker.internal:host-gateway \
--network ${launchedNetwork.networkName} \
${options.relayerImageTag} \
generate-beacon-checkpoint --config ${RELAYER_CONFIG_PATHS.BEACON} --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 initialCheckpointRaw = fs.readFileSync(INITIAL_CHECKPOINT_PATH, "utf-8");
const initialCheckpointFile = Bun.file(INITIAL_CHECKPOINT_PATH);
const initialCheckpointRaw = await initialCheckpointFile.text();
const initialCheckpoint = parseJsonToBeaconCheckpoint(JSON.parse(initialCheckpointRaw));
fs.unlinkSync(INITIAL_CHECKPOINT_PATH);
await initialCheckpointFile.delete();
logger.trace("Initial checkpoint:");
logger.trace(initialCheckpoint.toJSON());
@ -321,6 +363,7 @@ export const initEthClientPallet = async (
// 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");
};
/**

View file

@ -19,11 +19,6 @@ export const performSummaryOperations = async (
servicesToDisplay.push("datahaven-alice");
}
const activeRelayers = launchedNetwork.relayers;
for (const relayer of activeRelayers) {
servicesToDisplay.push(`${relayer}-relayer`);
}
logger.trace("Services to display", servicesToDisplay);
const displayData: { service: string; ports: Record<string, number>; url: string }[] = [];
@ -101,24 +96,6 @@ export const performSummaryOperations = async (
break;
}
case service === "beefy-relayer": {
displayData.push({
service,
ports: {},
url: "Background process (connects to other services)"
});
break;
}
case service === "beacon-relayer": {
displayData.push({
service,
ports: {},
url: "Background process (connects to other services)"
});
break;
}
default: {
logger.error(`Unknown service: ${service}`);
}
@ -127,8 +104,10 @@ export const performSummaryOperations = async (
const containers = launchedNetwork.containers.filter((c) => !c.name.startsWith("datahaven-"));
for (const { name, publicPorts } of containers) {
const url = "ws" in publicPorts ? `ws://127.0.0.1:${publicPorts.ws}` : "un-exposed";
displayData.push({ service: name, ports: publicPorts, url });
const url = "ws" in publicPorts ? `ws://127.0.0.1:${publicPorts.ws}` : undefined;
if (url) {
displayData.push({ service: name, ports: publicPorts, url });
}
}
console.table(displayData);

View file

@ -19,7 +19,7 @@ export const performValidatorOperations = async (
);
} else {
logger.info(
`Using flag option: ${shouldFundValidators ? "will fund" : "will not fund"} validators`
`🏳️ Using flag option: ${shouldFundValidators ? "will fund" : "will not fund"} validators`
);
}
@ -48,7 +48,7 @@ export const performValidatorOperations = async (
);
} else {
logger.info(
`Using flag option: ${shouldSetupValidators ? "will register" : "will not register"} validators`
`🏳️ Using flag option: ${shouldSetupValidators ? "will register" : "will not register"} validators`
);
}
@ -73,7 +73,7 @@ export const performValidatorOperations = async (
);
} else {
logger.info(
`Using flag option: ${shouldUpdateValidatorSet ? "will update" : "will not update"} validator set`
`🏳️ Using flag option: ${shouldUpdateValidatorSet ? "will update" : "will not update"} validator set`
);
}

View file

@ -46,7 +46,11 @@ const program = new Command()
"Tag of the datahaven image to use",
"moonsonglabs/datahaven:local"
)
.option("--relayer-bin-path <value>", "Path to the relayer binary", "tmp/bin/snowbridge-relay")
.option(
"-p, --relayer-image-tag <value>",
"Tag of the relayer",
"moonsonglabs/snowbridge-relayer:latest"
)
.hook("preAction", launchPreActionHook)
.action(launch);

View file

@ -8,16 +8,19 @@
"fmt": "biome check .",
"fmt:fix": "biome check --write .",
"build:docker:operator": "docker build -t moonsonglabs/datahaven:local -f ./docker/datahaven-node-local.dockerfile ../.",
"build:docker:operator:timbo": "docker build -t moonsonglabs/datahaven:local -f ./docker/Local.Dockerfile ../.",
"build:docker:relayer": "bun -e \"import build from './scripts/snowbridge-relayer.ts'; build()\"",
"generate:wagmi": "wagmi generate",
"generate:snowbridge-cfgs": "bun -e \"import {generateSnowbridgeConfigs} from './scripts/gen-snowbridge-cfgs.ts'; await generateSnowbridgeConfigs()\"",
"generate:types": "(cd ../operator && cargo build --release) && bun x papi add --wasm \"../operator/target/release/wbuild/datahaven-stagenet-runtime/datahaven_stagenet_runtime.wasm\" datahaven",
"start:e2e:verified": "bun cli --verified --blockscout --deploy-contracts --setup-validators --update-validator-set --fund-validators",
"start:e2e:verified:relayers": "bun cli --verified --blockscout --deploy-contracts --setup-validators --update-validator-set --fund-validators --slot-time 1 --relayer --datahaven",
"start:e2e:local": "LOG_LEVEL=debug bun start:e2e:ci --bd",
"start:e2e:ci": "bun cli --datahaven --no-build-datahaven --launch-kurtosis --deploy-contracts --fund-validators --setup-validators --update-validator-set --relayer --always-clean",
"start:e2e:minrelayer": "bun cli --relayer --deploy-contracts --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven",
"stop:docker": "docker ps -a --filter 'ancestor=moonsonglabs/datahaven:local' -q | xargs -r docker rm -f",
"stop:e2e": "bun stop:docker ;pkill datahaven ; pkill snowbridge-relay ; kurtosis enclave stop datahaven-ethereum && kurtosis clean && kurtosis engine stop && docker container prune -f",
"stop:docker:datahaven": "docker rm -f $(docker ps -aq --filter name='^datahaven-') 2>/dev/null || true",
"stop:docker:relayer": "docker rm -f $(docker ps -aq --filter name='^snowbridge-relayer-') 2>/dev/null || true",
"stop:e2e": "bun stop:docker:datahaven ; bun stop:docker:relayer ; (kurtosis enclave stop datahaven-ethereum || true) && kurtosis clean && kurtosis engine stop && docker container prune -f",
"start:e2e:minimal:relayer": "bun cli --relayer --deploy-contracts --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven",
"stop:e2e:verified": "bun stop:e2e",
"stop:e2e:quick": "kurtosis enclave stop datahaven-ethereum",

View file

@ -8,6 +8,7 @@ const LOG_LEVEL = Bun.env.LOG_LEVEL || "info";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RUNTIME_FEATURES = ["fast-runtime"];
export const cargoCrossbuild = async (options: {
datahavenBuildExtraArgs?: string;
@ -40,7 +41,7 @@ export const cargoCrossbuild = async (options: {
// Get additional arguments from command line
const additionalArgs = options.datahavenBuildExtraArgs ?? "";
const command = `cargo zigbuild --target ${target} --release ${additionalArgs}`;
const command = `cargo zigbuild --target ${target} --release ${additionalArgs} --features ${RUNTIME_FEATURES.join(",")}`;
logger.debug(`Running build command: ${command}`);
if (LOG_LEVEL === "debug") {
@ -53,7 +54,9 @@ export const cargoCrossbuild = async (options: {
} else if (ARCH === "x86_64" && OS === "Linux") {
logger.info("🖥️ Linux AMD64 detected. Proceeding with cross-building...");
const command = "cargo build --release";
const target = "x86_64-unknown-linux-gnu";
addRustupTarget(target);
const command = `cargo build --target ${target} --release --features ${RUNTIME_FEATURES.join(",")}`;
logger.debug(`Running build command: ${command}`);
if (LOG_LEVEL === "debug") {

View file

@ -38,7 +38,7 @@ export const deployContracts = async (options: DeployContractsOptions): Promise<
);
} else {
logger.info(
`Using flag option: ${shouldDeployContracts ? "will deploy" : "will not deploy"} smart contracts`
`🏳️ Using flag option: ${shouldDeployContracts ? "will deploy" : "will not deploy"} smart contracts`
);
}
@ -78,7 +78,7 @@ export const deployContracts = async (options: DeployContractsOptions): Promise<
logger.info("🔍 Contract verification enabled");
}
logger.info(" Deploying contracts (this might take a few minutes)...");
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" });

View file

@ -101,7 +101,7 @@ export const fundValidators = async (options: FundValidatorsOptions): Promise<bo
}
const validators = config.validators;
logger.info(`Found ${validators.length} validators to fund`);
logger.info(`🔎 Found ${validators.length} validators to fund`);
// Get cast path for transactions
const { stdout: castPath } = await $`which cast`.quiet();
@ -127,7 +127,7 @@ export const fundValidators = async (options: FundValidatorsOptions): Promise<bo
logger.debug(`Found ${deployments.DeployedStrategies.length} strategies with token information`);
// We need to ensure all operators to be registered have the necessary tokens
logger.info("Funding validators with tokens...");
logger.info("💸 Funding validators with tokens...");
// Iterate through the strategies, using the embedded token information to fund validators
for (const strategy of deployments.DeployedStrategies) {

View file

@ -96,12 +96,12 @@ export const setupValidators = async (options: SetupValidatorsOptions): Promise<
}
const validators = config.validators;
logger.info(`Found ${validators.length} validators to register`);
logger.info(`🔎 Found ${validators.length} validators to register`);
// Iterate through all validators to register them
for (let i = 0; i < validators.length; i++) {
const validator = validators[i];
logger.info(`Setting up validator ${i} (${validator.publicKey})`);
logger.info(`🔧 Setting up validator ${i} (${validator.publicKey})`);
const env = {
...process.env,

View file

@ -27,7 +27,7 @@ async function downloadRelayBinary() {
repo: "snowbridge"
});
const tagName = latestRelease.data.tag_name;
logger.info(`Found latest release: ${tagName}`);
logger.info(`🔎 Found latest release: ${tagName}`);
const relayAsset = latestRelease.data.assets.find((asset) => asset.name === "snowbridge-relay");

View file

@ -108,7 +108,7 @@ export async function waitForLog(opts: {
() =>
pass.destroy(
new Error(
`Timed out after ${timeoutMs} ms waiting for ${opts.search} in ${opts.containerName}`
`Timed out after ${timeoutMs} ms waiting for "${opts.search}" in ${opts.containerName}`
)
),
timeoutMs
@ -125,7 +125,7 @@ export async function waitForLog(opts: {
}
throw new Error(
`Log stream ended before ${opts.search} appeared for container ${opts.containerName}`
`Log stream ended before "${opts.search}" appeared for container ${opts.containerName}`
);
} finally {
if (timer) {
@ -172,3 +172,21 @@ export const waitForContainerToStart = async (
`❌ container ${containerName} cannot be found in running container list after ${seconds} seconds`
);
};
export const killExistingContainers = async (imageName: string) => {
logger.debug(`Searching for containers with image ${imageName}...`);
const docker = new Docker();
const containerInfos = (await docker.listContainers({ all: true })).filter((container) =>
container.Image.includes(imageName)
);
if (containerInfos.length === 0) {
logger.debug(`No containers found with image ${imageName}`);
return;
}
const promises = containerInfos.map(({ Id }) => docker.getContainer(Id).remove({ force: true }));
await Promise.all(promises);
logger.debug(`${containerInfos.length} containers with image ${imageName} killed`);
};

View file

@ -46,7 +46,9 @@ export const runShellCommandWithLogger = async (
const text = new TextDecoder().decode(value);
const trimmedText = text.trim();
if (trimmedText) {
logger[logLevel](trimmedText.includes("\n") ? `\n${trimmedText}` : trimmedText);
logger[logLevel](
trimmedText.includes("\n") ? `>_ \n${trimmedText}` : `>_ ${trimmedText}`
);
}
}
} catch (err) {

View file

@ -1,4 +1,5 @@
import { type FixedSizeArray, FixedSizeBinary } from "polkadot-api";
import { z } from "zod";
/**
* The type of the response from the `/eth/v1/beacon/states/head/finality_checkpoints`
@ -49,28 +50,6 @@ export interface BeaconCheckpoint {
toJSON: () => JsonBeaconCheckpoint;
}
/**
* Represents the structure of the BeaconCheckpoint as it might be after JSON.parse
* before specific type coercions (e.g., to BigInt).
*/
interface RawBeaconCheckpoint {
header: {
slot: number | string | bigint; // JSON.parse will yield number or string for big numbers
proposer_index: number | string | bigint; // Same as above
parent_root: string; // Assuming hex string
state_root: string; // Assuming hex string
body_root: string; // Assuming hex string
};
current_sync_committee: {
pubkeys: string[]; // Assuming array of hex strings
aggregate_pubkey: string; // Assuming hex string
};
current_sync_committee_branch: string[]; // Assuming array of hex strings
validators_root: string; // Assuming hex string
block_roots_root: string; // Assuming hex string
block_roots_branch: string[]; // Assuming array of hex strings
}
/**
* Represents the structure of a BeaconCheckpoint when serialized to JSON.
* BigInts are converted to strings, and FixedSizeBinary types are converted to hex strings.
@ -93,51 +72,35 @@ interface JsonBeaconCheckpoint {
block_roots_branch: string[];
}
/**
* Parses a JSON object into a BeaconCheckpoint.
*
* @param jsonInput - The JSON object to parse.
* @returns The parsed BeaconCheckpoint.
*/
export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => {
const raw = jsonInput as RawBeaconCheckpoint;
// Zod schema for hex strings, ensuring they start with "0x" if not empty
const hexStringSchema = z.union([
z.string().regex(/^0x[0-9a-fA-F]*$/, {
message: "Invalid hex string"
}),
z.literal("")
]);
// Basic validation
if (!raw || typeof raw.header !== "object" || raw.header === null) {
throw new Error("Invalid JSON structure for BeaconCheckpoint: missing or invalid header");
}
if (typeof raw.header.slot === "undefined" || typeof raw.header.proposer_index === "undefined") {
throw new Error(
"Invalid JSON structure for BeaconCheckpoint: header missing slot or proposer_index"
);
}
if (
!raw.current_sync_committee?.pubkeys ||
!raw.current_sync_committee.aggregate_pubkey ||
!Array.isArray(raw.current_sync_committee.pubkeys) ||
!Array.isArray(raw.current_sync_committee_branch) ||
!raw.validators_root ||
!raw.block_roots_root ||
!Array.isArray(raw.block_roots_branch)
) {
throw new Error(
"Invalid JSON structure for BeaconCheckpoint: missing sync-committee or root fields"
);
}
if (raw.current_sync_committee.pubkeys.length !== 512) {
throw new Error(
`Invalid sync-committee size. Expected 512 pubkeys, got ${raw.current_sync_committee.pubkeys.length}`
);
}
// Map pubkeys to FixedSizeBinary<48>
const pubkeys = new Array<FixedSizeBinary<48>>(512);
for (let i = 0; i < raw.current_sync_committee.pubkeys.length; i++) {
pubkeys[i] = new FixedSizeBinary<48>(hexToUint8Array(raw.current_sync_committee.pubkeys[i]));
}
// Zod schema for the RawBeaconCheckpoint structure
const rawBeaconCheckpointSchema = z.object({
header: z.object({
slot: z.union([z.number(), z.string(), z.bigint()]),
proposer_index: z.union([z.number(), z.string(), z.bigint()]),
parent_root: hexStringSchema,
state_root: hexStringSchema,
body_root: hexStringSchema
}),
current_sync_committee: z.object({
pubkeys: z.array(hexStringSchema).length(512),
aggregate_pubkey: hexStringSchema
}),
current_sync_committee_branch: z.array(hexStringSchema),
validators_root: hexStringSchema,
block_roots_root: hexStringSchema,
block_roots_branch: z.array(hexStringSchema)
});
// Zod schema for transforming RawBeaconCheckpoint to BeaconCheckpoint
const beaconCheckpointSchema = rawBeaconCheckpointSchema.transform((raw) => {
const checkpointData: Omit<BeaconCheckpoint, "toJSON"> = {
header: {
slot: BigInt(raw.header.slot),
@ -147,7 +110,12 @@ export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint =>
body_root: new FixedSizeBinary<32>(hexToUint8Array(raw.header.body_root))
},
current_sync_committee: {
pubkeys: asFixedSizeArray(pubkeys, 512),
pubkeys: asFixedSizeArray(
raw.current_sync_committee.pubkeys.map(
(pk) => new FixedSizeBinary<48>(hexToUint8Array(pk))
),
512
),
aggregate_pubkey: new FixedSizeBinary<48>(
hexToUint8Array(raw.current_sync_committee.aggregate_pubkey)
)
@ -186,6 +154,30 @@ export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint =>
};
}
};
});
/**
* Parses a JSON object into a BeaconCheckpoint.
*
* @param jsonInput - The JSON object to parse.
* @returns The parsed BeaconCheckpoint.
*/
export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => {
try {
return beaconCheckpointSchema.parse(jsonInput);
} catch (error) {
if (error instanceof z.ZodError) {
// You can customize error handling here, e.g., throw a more specific error
// or log the validation issues.
throw new Error(
`Invalid JSON structure for BeaconCheckpoint: ${error.errors
.map((e) => `${e.path.join(".")} - ${e.message}`)
.join(", ")}`
);
}
// Re-throw other errors
throw error;
}
};
/**
@ -217,6 +209,9 @@ const hexToUint8Array = (hex: string): Uint8Array => {
if (hexString.startsWith("0x")) {
hexString = hexString.slice(2);
}
if (hexString.length % 2 !== 0) {
throw new Error("Hex string must have an even number of characters");
}
return Buffer.from(hexString, "hex");
};

View file

@ -1,6 +1,7 @@
import { ANVIL_FUNDED_ACCOUNTS, CHAIN_ID, getRPCUrl, getWSUrl } from "utils";
import { http, createWalletClient, defineChain, publicActions } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import type { Prettify } from "./types";
export const createChainConfig = async () =>
defineChain({
@ -37,8 +38,6 @@ export const createDefaultClient = async () =>
transport: http()
}).extend(publicActions);
// export interface ViemClientInterface extends WalletClient, PublicActions {}
export type ViemClientInterface = Awaited<ReturnType<typeof createDefaultClient>>;
export type ViemClientInterface = Prettify<Awaited<ReturnType<typeof createDefaultClient>>>;
export const generateRandomAccount = () => privateKeyToAccount(generatePrivateKey());