test: 🐳 Add docker support for datahaven nodes (#71)

> [!NOTE]  
> This is  `Part 3` of the ongoing _Docker Series._


## New Additions:
- Launching Datahaven network will spin up containers, as opposed to
native binaries
- `stop:docker` script to kill all dh containers
- `e2e` test suite for datahaven solochain network
- Contains reference test file that uses papi for storage queries,
submitting exts, runtime calls (good job on that facu and tobi)
- Added new utils:
  - `waitForLog()` to wait for log lines in docker container logs
- `createPapiConnectors()` helper for test cases to build and connect to
dh network
- `getPapiSigner()` helper to return a papi compatible signer using our
prefunded accounts (alith by default)
- `sendTxn()` helper to submit txn and wait for block inclusion, instead
of finalization, which std library provides

## Changes:

> [!CAUTION] 
> Launching native binaries for datahaven no longer supported.


- Datahaven binary location cli option changed to `-i,
--datahaven-image-tag`
- To locally run this you'll need a datahaven docker image handy, you'll
need to either:
- Point to remote dockerhub e.g. `moonsonglabs/datahaven:main` (must be
logged in and have permission)
  - Build this locally with `bun build:docker:operator`

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

- **New Features**
- Added end-to-end tests for the Datahaven solochain, including runtime
API queries, storage lookups, extrinsic submissions, and event
listening.
- Introduced CLI option to specify the Datahaven Docker image tag, with
a default value.
  - Added CLI option to disable the Relayer.
- Provided new scripts to stop Docker containers associated with
Datahaven.
- Added utility functions for Docker log monitoring and container
startup checks.
- Introduced utilities for interacting with the Datahaven Polkadot API.

- **Improvements**
- Switched Datahaven network launch from local binaries to Docker
containers.
- Enhanced cache accuracy in build workflows by including Rust source
files in cache keys.
- Improved build performance with TypeScript incremental build options.
  - Increased timeout for end-to-end tests for better reliability.
  - Updated CLI version to 0.2.0.
  - Modified Dockerfile build to enable the `fast-runtime` feature.
- Extended network launch summary to include relayer and container
details.

- **Bug Fixes**
- Fixed cleanup logic by tracking and preparing for forced removal of
Docker containers after tests.

- **Chores**
  - Updated workflow steps for Docker image handling and network checks.
- Adjusted scripts and workflow logic for improved Docker and test
management.
- Removed top-level disk usage summaries from cleanup workflow for
streamlined reporting.
- Enhanced shell command utility to support asynchronous wait during
execution.
<!-- 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-16 15:17:05 +01:00 committed by GitHub
parent 625718c6d2
commit 82145b882b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 522 additions and 168 deletions

View file

@ -10,9 +10,6 @@ runs:
echo "Overall disk space before cleanup (df -h /):"
df -h /
echo "-------------------------------------------"
echo "Top-level directories before cleanup (du -h --max-depth=1 /):"
sudo du -h --max-depth=1 --exclude=/proc --exclude=/sys --exclude=/dev / | sort -rh | head -n 10
echo "-------------------------------------------"
echo "Detailed breakdown for /usr/ before cleanup:"
sudo du -h --max-depth=1 /usr/ | sort -rh | head -n 10
echo "-------------------------------------------"
@ -51,9 +48,6 @@ runs:
echo "Overall disk space after cleanup (df -h /):"
df -h /
echo "-------------------------------------------"
echo "Top-level directories after cleanup (du -h --max-depth=1 /):"
sudo du -h --max-depth=1 --exclude=/proc --exclude=/sys --exclude=/dev / | sort -rh | head -n 10
echo "-------------------------------------------"
echo "Detailed breakdown for /usr/ before cleanup:"
sudo du -h --max-depth=1 /usr/ | sort -rh | head -n 10
echo "-------------------------------------------"

View file

@ -65,8 +65,9 @@ jobs:
cargo-registry
cargo-git
sccache
key: cache-mount-${{ hashFiles('./operator/Dockerfile', './operator/Cargo.lock') }}
key: cache-mount-${{ hashFiles('./operator/Dockerfile', './operator/Cargo.lock') }}-${{hashFiles('./operator/runtime/**/*.rs','./operator/pallets/**/*.rs', './operator/node/**/*.rs')}}
restore-keys: |
cache-mount-${{ hashFiles('./operator/Dockerfile', './operator/Cargo.lock') }}
cache-mount-${{ hashFiles('./operator/Dockerfile') }}
cache-mount-
- name: Inject cache into docker

View file

@ -84,14 +84,11 @@ jobs:
chmod +x tmp/bin/snowbridge-relay
docker rm temp
- run: tmp/bin/snowbridge-relay --help
- name: Download datahaven binary
run: |
docker create --name temp ${{ inputs.image-tag }}
mkdir -p ../operator/target/release/
docker cp temp:/usr/local/bin/datahaven-node ../operator/target/release/
chmod +x ../operator/target/release/datahaven-node
docker rm temp
- run: ../operator/target/release/datahaven-node --help
- run: docker pull ${{ inputs.image-tag }}
- run: bun install
- run: bun start:e2e:ci
- run: bun start:e2e:ci --datahaven-image-tag ${{ inputs.image-tag }}
- name: Check network
run: |
kurtosis enclave inspect datahaven-ethereum
docker container ls
- run: bun test:e2e

View file

@ -27,6 +27,7 @@
"trailingCommas": "none",
"semicolons": "always",
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"quoteStyle": "double"
}
@ -35,6 +36,10 @@
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "warn"
},
"suspicious": {
"noExplicitAny": "off",
"noAsyncPromiseExecutor": "off"

View file

@ -3,6 +3,7 @@ FROM docker.io/paritytech/ci-unified:bullseye-1.85.0 AS base
ARG MOLD_VERSION=2.39.0
ARG SCCACHE_VERSION=0.10.0
ARG FAST_RUNTIME=TRUE
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
xz-utils \
@ -43,7 +44,11 @@ COPY ./operator/ .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/usr/local/sccache,sharing=locked \
cargo build --locked --release
if [ "$FAST_RUNTIME" = "TRUE" ]; then \
cargo build --locked --release --features fast-runtime; \
else \
cargo build --locked --release; \
fi
# --- Create final lightweight runtime image ---
FROM docker.io/parity/base-bin:latest

View file

@ -7,26 +7,31 @@ import { type PolkadotClient, createClient } 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 { waitForContainerToStart } from "utils";
import { confirmWithTimeout, logger, printDivider, printHeader } from "utils";
import { type Hex, keccak256, toHex } from "viem";
import { publicKeyToAddress } from "viem/utils";
import { publicKeyToAddress } from "viem/accounts";
import type { LaunchOptions } from ".";
import type { LaunchedNetwork } from "./launchedNetwork";
const COMMON_LAUNCH_ARGS = [
"--unsafe-force-node-key-generation",
"--tmp",
"--port=0",
"--validator",
"--discover-local",
"--no-prometheus",
"--unsafe-rpc-external",
"--rpc-cors=all",
"--force-authoring",
"--no-telemetry",
"--enable-offchain-indexing=true"
];
const DEFAULT_PUBLIC_WS_PORT = 9944;
// We need 5 since the (2/3 + 1) of 6 authority set is 5
// <repo_root>/operator/runtime/src/genesis_config_presets.rs#L94
const CLI_AUTHORITY_IDS = ["alice", "bob", "charlie", "dave", "eve"];
const CLI_AUTHORITY_IDS = ["alice", "bob", "charlie", "dave", "eve"] as const;
// 33-byte compressed public keys for DataHaven next validator set
// These correspond to Alice, Bob, Charlie, Dave, Eve, Ferdie
@ -38,7 +43,7 @@ const FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS: Record<string, string> = {
dave: "0x0291f1217d5a04cb83312ee3d88a6e6b33284e053e6ccfc3a90339a0299d12967c",
eve: "0x0389411795514af1627765eceffcbd002719f031604fadd7d188e2dc585b4e1afb",
ferdie: "0x03bc9d0ca094bd5b8b3225d7651eac5d18c1c04bf8ae8f8b263eebca4e1410ed0c"
};
} as const;
/**
* Prepares the configuration for DataHaven authorities by converting their
@ -51,7 +56,7 @@ export async function setupDataHavenValidatorConfig(
logger.info(`🔧 Preparing DataHaven authorities configuration for network: ${networkName}...`);
let authorityPublicKeys: string[] = [];
const dhNodes = launchedNetwork.getDHNodes();
const dhNodes = launchedNetwork.containers.filter((x) => x.name.startsWith("datahaven-"));
if (dhNodes.length === 0) {
logger.warn(
@ -60,11 +65,11 @@ export async function setupDataHavenValidatorConfig(
authorityPublicKeys = Object.values(FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS);
} else {
const firstNode = dhNodes[0];
const wsUrl = `ws://127.0.0.1:${firstNode.port}`;
const wsUrl = `ws://127.0.0.1:${firstNode.publicPorts.ws}`;
let papiClient: PolkadotClient | undefined;
try {
logger.info(
`📡 Attempting to fetch BEEFY next authorities from node ${firstNode.id} (port ${firstNode.port})...`
`📡 Attempting to fetch BEEFY next authorities from node ${firstNode.name} (port ${firstNode.publicPorts.ws})...`
);
papiClient = createClient(withPolkadotSdkCompat(getWsProvider(wsUrl)));
const dhApi = papiClient.getTypedApi(datahaven);
@ -84,14 +89,14 @@ export async function setupDataHavenValidatorConfig(
);
authorityPublicKeys = Object.values(FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS);
}
await papiClient.destroy();
papiClient.destroy();
} catch (error) {
logger.error(
`❌ Error fetching BEEFY next authorities from node ${firstNode.id}: ${error}. Falling back to hardcoded authority set.`
`❌ Error fetching BEEFY next authorities from node ${firstNode.name}: ${error}. Falling back to hardcoded authority set.`
);
authorityPublicKeys = Object.values(FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS);
if (papiClient) {
await papiClient.destroy(); // Ensure client is destroyed even on error
papiClient.destroy();
}
}
}
@ -151,7 +156,6 @@ export async function setupDataHavenValidatorConfig(
}
}
// TODO: This is very rough and will need something more substantial when we know what we want!
/**
* Launches a DataHaven solochain network for testing.
*
@ -165,6 +169,41 @@ export const launchDataHavenSolochain = async (
printHeader("Starting DataHaven Network");
let shouldLaunchDataHaven = options.datahaven;
if ((await checkDataHavenRunning()) && !options.alwaysClean) {
logger.info(" DataHaven network (Docker containers) is already running.");
logger.trace("Checking if datahaven option was set via flags");
if (options.datahaven === false) {
logger.info("Keeping existing DataHaven containers.");
await registerNodes(launchedNetwork);
printDivider();
return;
}
if (options.datahaven === true) {
logger.info("Proceeding to clean and relaunch DataHaven containers...");
await cleanDataHavenContainers();
} else {
const shouldRelaunch = await confirmWithTimeout(
"Do you want to clean and relaunch the DataHaven containers?",
true,
10
);
if (!shouldRelaunch) {
logger.info("Keeping existing DataHaven containers.");
await registerNodes(launchedNetwork);
printDivider();
return;
}
logger.info("Proceeding to clean and relaunch DataHaven containers...");
await cleanDataHavenContainers();
}
}
if (shouldLaunchDataHaven === undefined) {
shouldLaunchDataHaven = await confirmWithTimeout(
"Do you want to launch the DataHaven network?",
@ -183,15 +222,9 @@ export const launchDataHavenSolochain = async (
return;
}
// Kill any pre-existing datahaven processes if they exist
await $`pkill datahaven`.nothrow().quiet();
invariant(options.datahavenImageTag, "❌ Datahaven image tag not defined");
invariant(options.datahavenBinPath, "❌ DataHaven binary path not defined");
invariant(
await Bun.file(options.datahavenBinPath).exists(),
"❌ DataHaven binary does not exist"
);
await checkTagExists(options.datahavenImageTag);
const logsPath = `tmp/logs/${launchedNetwork.getRunId()}/`;
logger.debug(`Ensuring logs directory exists: ${logsPath}`);
@ -199,57 +232,46 @@ export const launchDataHavenSolochain = async (
for (const id of CLI_AUTHORITY_IDS) {
logger.info(`Starting ${id}...`);
const containerName = `datahaven-${id}`;
const command: string[] = [options.datahavenBinPath, ...COMMON_LAUNCH_ARGS, `--${id}`];
const command: string[] = [
"docker",
"run",
"-d",
"--platform",
"linux/amd64",
"--name",
containerName,
...(id === "alice" ? ["-p", `${DEFAULT_PUBLIC_WS_PORT}:9944`] : []),
options.datahavenImageTag,
`--${id}`,
...COMMON_LAUNCH_ARGS
];
const logFileName = `datahaven-${id}.log`;
const logFilePath = path.join(logsPath, logFileName);
logger.debug(`Writing logs to ${logFilePath}`);
logger.debug($`sh -c "${command.join(" ")}"`.text());
const fd = fs.openSync(logFilePath, "a");
launchedNetwork.addFileDescriptor(fd);
await waitForContainerToStart(containerName);
logger.debug(`Spawning command: ${command.join(" ")}`);
const process = Bun.spawn(command, {
stdout: fd,
stderr: fd
});
process.unref();
let completed = false;
const file = Bun.file(logFilePath);
for (let i = 0; i < 60; i++) {
const pattern = "Running JSON-RPC server: addr=127.0.0.1:";
const blob = await file.text();
logger.debug(`Blob: ${blob}`);
if (blob.includes(pattern)) {
const port = blob.split(pattern)[1].split("\n")[0].replaceAll(",", "");
launchedNetwork.addDHNode(id, Number.parseInt(port));
logger.debug(`${id} started at port ${port}`);
completed = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
invariant(completed, "❌ Could not find 'Running JSON-RPC server:' in logs");
launchedNetwork.addProcess(process);
logger.debug(`Started ${id} at ${process.pid}`);
// TODO: Un-comment this when it doesn't stop process from hanging
// This is working on SH, but not here so probably a Bun defect
//
// const listeningLine = await waitForLog({
// search: "Running JSON-RPC server: addr=0.0.0.0:",
// containerName,
// timeoutSeconds: 30
// });
// logger.debug(listeningLine);
}
// Check if network is ready
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 30; i++) {
logger.info("Waiting for datahaven to start...");
// Get the port of the primary node (or default)
const primaryNodePort = launchedNetwork.getDHNodes()[0]?.port || 9944;
if (await isNetworkReady(primaryNodePort)) {
if (await isNetworkReady(DEFAULT_PUBLIC_WS_PORT)) {
logger.success(
`DataHaven network started, primary node accessible on port ${primaryNodePort}`
`DataHaven network started, primary node accessible on port ${DEFAULT_PUBLIC_WS_PORT}`
);
await registerNodes(launchedNetwork);
// Call setupDataHavenValidatorConfig now that nodes are up
logger.info("Proceeding with DataHaven validator configuration setup...");
await setupDataHavenValidatorConfig(launchedNetwork);
@ -261,9 +283,47 @@ export const launchDataHavenSolochain = async (
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("DataHaven network failed to start after 10 seconds");
throw new Error("Datahaven network failed to start after 30 seconds");
};
/**
* Checks if any DataHaven containers are currently running.
*
* @returns True if any DataHaven containers are running, false otherwise.
*/
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;
};
/**
* Stops and removes all DataHaven containers.
*/
const cleanDataHavenContainers = async (): 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());
}
}
logger.info("✅ Existing DataHaven containers stopped and removed.");
};
/**
* Checks if the DataHaven network is ready by sending a POST request to the system_chain method.
*
* @param port - The port number to check.
* @returns True if the network is ready, false otherwise.
*/
export const isNetworkReady = async (port: number): Promise<boolean> => {
const wsUrl = `ws://127.0.0.1:${port}`;
let client: PolkadotClient | undefined;
@ -272,17 +332,65 @@ export const isNetworkReady = async (port: number): Promise<boolean> => {
client = createClient(withPolkadotSdkCompat(getWsProvider(wsUrl)));
const chainName = await client._request<string>("system_chain", []);
logger.debug(`isNetworkReady PAPI check successful for port ${port}, chain: ${chainName}`);
await client.destroy();
client.destroy();
return !!chainName; // Ensure it's a boolean and chainName is truthy
} catch (error) {
logger.debug(`isNetworkReady PAPI check failed for port ${port}: ${error}`);
if (client) {
await client.destroy();
client.destroy();
}
return false;
}
};
/**
* Checks if an image exists locally or on Docker Hub.
*
* @param tag - The tag of the image to check.
* @returns A promise that resolves when the image is found.
*/
const checkTagExists = async (tag: string) => {
const cleaned = tag.trim();
logger.debug(`Checking if image ${cleaned} is available locally`);
const { exitCode: localExists } = await $`docker image inspect ${cleaned}`.nothrow().quiet();
if (localExists !== 0) {
logger.debug(`Checking if image ${cleaned} is available on docker hub`);
const result = await $`docker manifest inspect ${cleaned}`.nothrow().quiet();
invariant(
result.exitCode === 0,
`❌ Image ${tag} not found.\n Does this image exist?\n Are you logged and have access to the repository?`
);
}
logger.success(`Image ${tag} found locally`);
};
const registerNodes = async (launchedNetwork: LaunchedNetwork) => {
const targetContainerName = "datahaven-alice";
const aliceHostWsPort = 9944; // Standard host port for Alice's WS, as set during launch.
logger.debug(`Checking Docker status for container: ${targetContainerName}`);
// Use ^ and $ for an exact name match in the filter.
const dockerPsOutput = await $`docker ps -q --filter "name=^${targetContainerName}$"`.text();
const isContainerRunning = dockerPsOutput.trim().length > 0;
if (!isContainerRunning) {
// If the target Docker container is not running, we cannot register it.
throw new Error(
`❌ Docker container ${targetContainerName} is not running. Cannot register node.`
);
}
// 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}.`
);
launchedNetwork.addContainer(targetContainerName, { ws: aliceHostWsPort });
logger.success(`👍 Node ${targetContainerName} successfully registered in launchedNetwork.`);
};
// Function to convert compressed public key to Ethereum address
export const compressedPubKeyToEthereumAddress = (compressedPubKey: string): string => {
// Ensure the input is a hex string and remove "0x" prefix

View file

@ -21,8 +21,8 @@ export interface LaunchOptions {
relayerBinPath?: string;
skipCleaning?: boolean;
alwaysClean?: boolean;
datahavenBinPath?: string;
datahaven?: boolean;
datahavenImageTag?: string;
kurtosisNetworkArgs?: string;
slotTime?: number;
}
@ -63,13 +63,13 @@ const launchFunction = async (options: LaunchOptions, launchedNetwork: LaunchedN
}
const contractsDeployed = await deployContracts({
rpcUrl: launchedNetwork.getElRpcUrl(),
rpcUrl: launchedNetwork.elRpcUrl,
verified: options.verified,
blockscoutBackendUrl,
deployContracts: options.deployContracts
});
await performValidatorOperations(options, launchedNetwork.getElRpcUrl(), contractsDeployed);
await performValidatorOperations(options, launchedNetwork.elRpcUrl, contractsDeployed);
await launchRelayers(options, launchedNetwork);

View file

@ -23,6 +23,7 @@ export const launchKurtosis = async (
logger.trace("Checking if launchKurtosis option was set via flags");
if (options.launchKurtosis === false) {
logger.info("Keeping existing Kurtosis enclave.");
await registerServices(launchedNetwork);
printDivider();
return;
@ -39,6 +40,7 @@ export const launchKurtosis = async (
if (!shouldRelaunch) {
logger.info("Keeping existing Kurtosis enclave.");
await registerServices(launchedNetwork);
printDivider();
return;
@ -146,7 +148,7 @@ const registerServices = async (launchedNetwork: LaunchedNetwork) => {
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.setElRpcUrl(elRpcUrl);
launchedNetwork.elRpcUrl = elRpcUrl;
logger.info(`👍 Execution Layer RPC URL configured: ${elRpcUrl}`);
// Configure CL Endpoint
@ -156,6 +158,6 @@ const registerServices = async (launchedNetwork: LaunchedNetwork) => {
clEndpoint,
"❌ CL Endpoint could not be determined from Kurtosis service cl-1-lighthouse-reth"
);
launchedNetwork.setClEndpoint(clEndpoint);
launchedNetwork.clEndpoint = clEndpoint;
logger.info(`👍 Consensus Layer Endpoint configured: ${clEndpoint}`);
};

View file

@ -1,6 +1,10 @@
import fs from "node:fs";
import invariant from "tiny-invariant";
import { logger } from "utils";
import { type RelayerType, logger } from "utils";
type PipeOptions = number | "inherit" | "pipe" | "ignore";
type BunProcess = Bun.Subprocess<PipeOptions, PipeOptions, PipeOptions>;
type ContainerSpec = { name: string; publicPorts: Record<string, number> };
/**
* Represents the state and associated resources of a launched network environment,
@ -8,21 +12,23 @@ import { logger } from "utils";
*/
export class LaunchedNetwork {
protected runId: string;
protected processes: Bun.Subprocess<"inherit" | "pipe" | "ignore", number, number>[];
protected processes: BunProcess[];
protected _containers: ContainerSpec[];
protected fileDescriptors: number[];
protected DHNodes: { id: string; port: number }[];
protected _activeRelayers: RelayerType[];
/** The RPC URL for the Ethereum Execution Layer (EL) client. */
protected elRpcUrl?: string;
protected _elRpcUrl?: string;
/** The HTTP endpoint for the Ethereum Consensus Layer (CL) client. */
protected clEndpoint?: string;
protected _clEndpoint?: string;
constructor() {
this.runId = crypto.randomUUID();
this.processes = [];
this.fileDescriptors = [];
this.DHNodes = [];
this.elRpcUrl = undefined;
this.clEndpoint = undefined;
this._containers = [];
this._activeRelayers = [];
this._elRpcUrl = undefined;
this._clEndpoint = undefined;
}
/**
@ -34,23 +40,17 @@ export class LaunchedNetwork {
}
/**
* Gets the list of launched DataHaven (DH) nodes.
* @returns An array of DH node objects, each with an id and port.
* Gets the port for a DataHaven RPC node.
*
* In reality, it just looks for the "ws" port of the
* `datahaven-alice` container, if it was registered.
* @returns The port number of the container, or -1 if ws port is not found.
* @throws If the container is not found.
*/
getDHNodes(): { id: string; port: number }[] {
return [...this.DHNodes];
}
/**
* Gets the port for a specific DataHaven (DH) node by its ID.
* @param id - The ID of the DH node.
* @returns The port number of the DH node.
* @throws If the node with the given ID is not found.
*/
getDHPort(id: string): number {
const node = this.DHNodes.find((x) => x.id === id);
invariant(node, `❌ DataHaven node ${id} not found`);
return node.port;
getContainerPort(id: string): number {
const container = this._containers.find((x) => x.name === id);
invariant(container, `❌ Container ${id} not found`);
return container.publicPorts.ws ?? -1;
}
/**
@ -65,25 +65,42 @@ export class LaunchedNetwork {
* Adds a running process to be managed and cleaned up.
* @param process - The Bun subprocess object.
*/
addProcess(process: Bun.Subprocess<"inherit" | "pipe" | "ignore", number, number>) {
addProcess(process: BunProcess) {
this.processes.push(process);
}
/**
* Adds a DataHaven (DH) node to the list of launched nodes.
* @param id - The ID of the DH node.
* @param port - The port number the DH node is running on.
*/
addDHNode(id: string, port: number) {
this.DHNodes.push({ id, port });
addContainer(containerName: string, publicPorts: Record<string, number> = {}) {
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:");
logger.debug(JSON.stringify(this.containers));
const port = this.containers.map((x) => x.publicPorts.ws).find((x) => x !== -1);
invariant(port !== undefined, "❌ No public port found in containers");
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.
*/
setElRpcUrl(url: string) {
this.elRpcUrl = url;
public set elRpcUrl(url: string) {
this._elRpcUrl = url;
}
/**
@ -91,17 +108,17 @@ export class LaunchedNetwork {
* @returns The EL RPC URL string.
* @throws If the EL RPC URL has not been set.
*/
getElRpcUrl(): string {
invariant(this.elRpcUrl, "❌ EL RPC URL not set in LaunchedNetwork");
return this.elRpcUrl;
public get elRpcUrl(): string {
invariant(this._elRpcUrl, "❌ EL RPC URL not set in LaunchedNetwork");
return this._elRpcUrl;
}
/**
* Sets the HTTP endpoint for the Ethereum Consensus Layer (CL) client.
* @param url - The CL HTTP endpoint string.
*/
setClEndpoint(url: string) {
this.clEndpoint = url;
public set clEndpoint(url: string) {
this._clEndpoint = url;
}
/**
@ -109,14 +126,16 @@ export class LaunchedNetwork {
* @returns The CL HTTP endpoint string.
* @throws If the CL HTTP endpoint has not been set.
*/
getClEndpoint(): string {
invariant(this.clEndpoint, "❌ CL HTTP Endpoint not set in LaunchedNetwork");
return this.clEndpoint;
public get clEndpoint(): string {
invariant(this._clEndpoint, "❌ CL HTTP Endpoint not set in LaunchedNetwork");
return this._clEndpoint;
}
async cleanup() {
logger.debug("Running cleanup");
for (const process of this.processes) {
logger.debug(`Process is still running: ${process.pid}`);
process.unref();
}
for (const fd of this.fileDescriptors) {

View file

@ -69,7 +69,9 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
}
// Get DataHaven node port
const dhNodes = launchedNetwork.getDHNodes();
const dhNodes = launchedNetwork.containers.filter((container) =>
container.name.includes("datahaven")
);
let substrateWsPort: number;
let substrateNodeId: string;
@ -81,8 +83,8 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
substrateNodeId = "default (assumed)";
} else {
const firstDhNode = dhNodes[0];
substrateWsPort = firstDhNode.port;
substrateNodeId = firstDhNode.id;
substrateWsPort = firstDhNode.publicPorts.ws;
substrateNodeId = firstDhNode.name;
logger.info(
`🔌 Using DataHaven node ${substrateNodeId} on port ${substrateWsPort} for relayers and BEEFY check.`
);
@ -238,7 +240,7 @@ const waitBeefyReady = async (
pollIntervalMs: number,
timeoutMs: number
): Promise<void> => {
const port = launchedNetwork.getDHNodes()[0]?.port ?? 9944;
const port = launchedNetwork.getPublicWsPort();
const wsUrl = `ws://127.0.0.1:${port}`;
const maxAttempts = Math.floor(timeoutMs / pollIntervalMs);
@ -255,7 +257,7 @@ const waitBeefyReady = async (
if (finalizedHeadHex && finalizedHeadHex !== ZERO_HASH) {
logger.success(`🥩 BEEFY is ready. Finalized head: ${finalizedHeadHex}`);
await client.destroy();
client.destroy();
return;
}
@ -270,12 +272,12 @@ const waitBeefyReady = async (
}
logger.error(`❌ BEEFY failed to become ready after ${timeoutMs / 1000} seconds`);
if (client) await client.destroy();
if (client) client.destroy();
throw new Error("BEEFY protocol not ready. Relayers cannot be launched.");
} catch (error) {
logger.error(`❌ Failed to connect to DataHaven node for BEEFY check: ${error}`);
if (client) {
await client.destroy();
client.destroy();
}
throw new Error("BEEFY protocol not ready. Relayers cannot be launched.");
}
@ -317,7 +319,7 @@ export const initEthClientPallet = async (
logger.trace(initialCheckpoint.toJSON());
// Send the checkpoint to the Substrate runtime
const substrateRpcUrl = `http://127.0.0.1:${launchedNetwork.getDHNodes()[0].port}`;
const substrateRpcUrl = `http://127.0.0.1:${launchedNetwork.getPublicWsPort()}`;
await sendCheckpointToSubstrate(substrateRpcUrl, initialCheckpoint);
};
@ -344,7 +346,7 @@ const waitBeaconChainReady = async (
while (keepPolling) {
try {
const response = await fetch(
`${launchedNetwork.getClEndpoint()}/eth/v1/beacon/states/head/finality_checkpoints`
`${launchedNetwork.clEndpoint}/eth/v1/beacon/states/head/finality_checkpoints`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);

View file

@ -15,9 +15,13 @@ export const performSummaryOperations = async (
servicesToDisplay.push(...["blockscout", "blockscout-frontend"]);
}
const dhNodes = launchedNetwork.getDHNodes();
for (const { id } of dhNodes) {
servicesToDisplay.push(`datahaven-${id}`);
if (launchedNetwork.containers.find((c) => c.name === "datahaven-alice")) {
servicesToDisplay.push("datahaven-alice");
}
const activeRelayers = launchedNetwork.relayers;
for (const relayer of activeRelayers) {
servicesToDisplay.push(`${relayer}-relayer`);
}
logger.trace("Services to display", servicesToDisplay);
@ -87,21 +91,45 @@ export const performSummaryOperations = async (
break;
}
case service.startsWith("datahaven-"): {
const port = launchedNetwork.getDHPort(service.split("datahaven-")[1]);
case service === "datahaven-alice": {
const port = launchedNetwork.getContainerPort(service);
displayData.push({
service,
ports: { http: port },
ports: { ws: port },
url: `http://127.0.0.1:${port}`
});
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}`);
}
}
}
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 });
}
console.table(displayData);
};

View file

@ -35,9 +35,9 @@ const program = new Command()
.option("--always-clean", "Always clean Kurtosis", false)
.option("--skip-cleaning", "Skip cleaning Kurtosis")
.option(
"--datahaven-bin-path <value>",
"Path to the datahaven binary",
"../operator/target/release/datahaven-node"
"-i, --datahaven-image-tag <value>",
"Tag of the datahaven image to use",
"moonsonglabs/datahaven:local"
)
.option("--relayer-bin-path <value>", "Path to the relayer binary", "tmp/bin/snowbridge-relay")
.hook("preAction", launchPreActionHook)
@ -45,7 +45,7 @@ const program = new Command()
// ===== Program =====
program
.version("0.1.0")
.version("0.2.0")
.name("bun cli")
.summary("🫎 DataHaven: Network Launcher CLI")
.usage("[options]")

View file

@ -7,7 +7,7 @@
"cli": "bun run cli/index.ts",
"fmt": "biome check .",
"fmt:fix": "biome check --write .",
"build:docker:operator": "docker buildx build -t moonsonglabs/datahaven-node:local -f ../operator/Dockerfile ../.",
"build:docker:operator": "docker buildx build --platform=linux/amd64 -t moonsonglabs/datahaven:local -f ../operator/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()\"",
@ -15,12 +15,14 @@
"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:ci": "bun cli --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",
"start:e2e:minimal:relayer": "bun cli --relayer --deploy-contracts --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven",
"stop:e2e": "pkill datahaven ; pkill snowbridge-relay ; kurtosis enclave stop datahaven-ethereum && kurtosis clean && kurtosis engine stop && docker container prune -f",
"stop:e2e:verified": "bun stop:e2e",
"stop:e2e:quick": "kurtosis enclave stop datahaven-ethereum",
"stop:kurtosis-engine": "kurtosis engine stop && docker container prune -f",
"test:e2e": "bun test suites/e2e --timeout 30000",
"test:e2e": "bun test suites/e2e --timeout 60000",
"typecheck": "tsc --noEmit",
"postinstall": "papi"
},

View file

@ -339,7 +339,7 @@ async function waitBeaconChainReady(options: SnowbridgeConfigOptions): Promise<v
logger.info(`Beacon chain finalized. Finalized root: ${initialBeaconBlock}`);
return;
}
} catch (error) {
} catch (_error) {
logger.trace({ attempt: i + 1 }, "Beacon finality check failed or not ready, retrying...");
}
await new Promise((resolve) => setTimeout(resolve, 1000));

View file

@ -3,7 +3,7 @@ import path from "node:path";
import { $ } from "bun";
import { Octokit } from "octokit";
import invariant from "tiny-invariant";
import { logger, printDivider, printHeader } from "utils";
import { logger, printHeader } from "utils";
const IMAGE_NAME = "snowbridge-relay:local";
const RELATIVE_DOCKER_FILE_PATH = "../../docker/SnowbridgeRelayer.dockerfile";

View file

@ -0,0 +1,74 @@
import { beforeAll, describe, expect, it } from "bun:test";
import type { PolkadotSigner } from "polkadot-api";
import {
type DataHavenApi,
SUBSTRATE_FUNDED_ACCOUNTS,
createPapiConnectors,
generateRandomAccount,
getPapiSigner,
logger
} from "utils";
import { isAddress, parseEther } from "viem";
describe("Datahaven solochain", () => {
let api: DataHavenApi;
let signer: PolkadotSigner;
beforeAll(() => {
const { typedApi } = createPapiConnectors();
api = typedApi;
signer = getPapiSigner();
});
it("Can query runtime API", async () => {
const address = await api.apis.EthereumRuntimeRPCApi.author();
logger.debug(`Author Address is: ${address.asHex()}`);
expect(isAddress(address.asHex())).toBeTrue();
});
it("Can lookup storages ", async () => {
const {
data: { free: freeBalance }
} = await api.query.System.Account.getValue(SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey);
logger.debug(`Balance of ALITH on DH is ${freeBalance}`);
expect(freeBalance).toBeGreaterThan(0n);
});
it("Can submit extrinsics into finalized block", async () => {
const value = parseEther("1");
const { address: dest } = generateRandomAccount();
const ext = api.tx.Balances.transfer_allow_death({
dest,
value
});
// This will wait until finalized block
const resp = await ext.signAndSubmit(signer, {});
logger.debug(`Transaction in finalized block: ${resp.txHash}`);
});
// This is way faster and should be how we submit build tests
it("Can submit extrinsics into best block", async () => {
const value = parseEther("1");
const { address: dest } = generateRandomAccount();
const ext = api.tx.Balances.transfer_allow_death({
dest,
value
});
const resp = await ext.signAndSubmit(signer, { at: "best" });
logger.debug(`Transaction submitted: ${resp.txHash}`);
const {
data: { free: freeBalance }
} = await api.query.System.Account.getValue(dest, { at: "best" });
logger.debug(`Balance of ${dest} on DH is ${freeBalance}`);
expect(freeBalance).toBeGreaterThan(0n);
});
it("Can listen to events", async () => {
const event = await api.event.System.ExtrinsicSuccess.pull();
logger.debug(event[0]);
expect(event).not.toBeEmpty();
expect(event[0].payload.dispatch_info.weight.ref_time).toBeGreaterThan(0n);
});
});

View file

@ -1,14 +1,5 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { beefyClientAbi } from "contract-bindings";
import {
type AnvilDeployments,
type ContractInstance,
type ViemClientInterface,
createDefaultClient,
getContractInstance,
logger,
parseDeploymentsFile
} from "utils";
import { type ContractInstance, getContractInstance, logger } from "utils";
import { isAddress } from "viem";
describe("BeefyClient contract", async () => {

View file

@ -25,7 +25,11 @@
"baseUrl": "./",
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true
"resolveJsonModule": true,
// Speed
"tsBuildInfoFile": "tmp/tsconfig.tsbuildinfo",
"assumeChangesOnlyAffectDirectDependencies": true
},
"include": [
"utils/*.ts",

View file

@ -1,12 +1,12 @@
import { type Duplex, PassThrough, Transform } from "node:stream";
import Docker from "dockerode";
import invariant from "tiny-invariant";
import { type ServiceInfo, type ServiceMapping, StandardServiceMappings, logger } from "utils";
import { type ServiceInfo, StandardServiceMappings, logger } from "utils";
const docker = new Docker({});
export const getServicesFromDocker = async (): Promise<ServiceInfo[]> => {
const docker = new Docker();
const containers = await docker.listContainers();
const services: ServiceInfo[] = [];
for (const mapping of StandardServiceMappings) {
@ -83,3 +83,92 @@ export const getPublicPort = async (
invariant(portMappings, `❌ port mapping not found for ${containerName}:${internalPort}`);
return portMappings.PublicPort;
};
export async function waitForLog(opts: {
search: string | RegExp;
containerName: string;
timeoutSeconds?: number;
}): Promise<string> {
const container = docker.getContainer(opts.containerName);
await container.inspect();
const timeoutMs = (opts.timeoutSeconds ?? 10) * 1_000;
const rawStream = (await container.logs({
stdout: true,
stderr: true,
follow: true,
since: 0
})) as Duplex;
const pass = new PassThrough();
container.modem.demuxStream(rawStream, pass, pass);
const { readable } = Transform.toWeb(pass);
const decoder = new TextDecoder();
const timer = setTimeout(
() =>
pass.destroy(
new Error(
`Timed out after ${timeoutMs} ms waiting for “${opts.search}” in ${opts.containerName}`
)
),
timeoutMs
);
try {
for await (const chunk of readable) {
const text = decoder.decode(chunk as Uint8Array, { stream: false });
const hit =
typeof opts.search === "string" ? text.includes(opts.search) : opts.search.test(text);
if (hit) return text.trim();
}
throw new Error(
`Log stream ended before “${opts.search}” appeared for container ${opts.containerName}`
);
} finally {
if (timer) {
clearTimeout(timer);
}
if (pass && typeof pass.destroy === "function" && !pass.destroyed) {
pass.destroy();
}
if (rawStream) {
if (typeof rawStream.destroy === "function" && !rawStream.destroyed) {
rawStream.destroy();
}
const socket = (rawStream as any).socket;
if (socket && typeof socket.destroy === "function" && !socket.destroyed) {
socket.destroy();
}
}
}
}
export const waitForContainerToStart = async (
containerName: string,
options?: { timeoutSeconds?: number }
) => {
logger.debug(`Waiting for container ${containerName} to start...`);
const docker = new Docker();
const seconds = options?.timeoutSeconds ?? 30;
for (let i = 0; i < seconds; i++) {
const containers = await docker.listContainers();
const container = containers.find((container) =>
container.Names.some((name) => name.includes(containerName))
);
if (container) {
logger.debug(`Container ${containerName} started after ${i} seconds`);
return;
}
await Bun.sleep(1000);
}
invariant(
false,
`❌ container ${containerName} cannot be found in running container list after ${seconds} seconds`
);
};

View file

@ -63,7 +63,6 @@ const portDetailSchema = z.object({
});
const portsListSchema = z.record(z.string(), portDetailSchema);
type PortsList = z.infer<typeof portsListSchema>;
const serviceSchema = z.object({
image: z.string(),

View file

@ -1,6 +1,12 @@
import { secp256k1 } from "@noble/curves/secp256k1";
import { keccak_256 } from "@noble/hashes/sha3";
import { datahaven } from "@polkadot-api/descriptors";
import { type PolkadotClient, type TypedApi, createClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { type PolkadotSigner, getPolkadotSigner } from "polkadot-api/signer";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { SUBSTRATE_FUNDED_ACCOUNTS } from "./constants";
import type { Prettify } from "./types";
// A signer for EVM like chains that use AccountId20 as their public address
export const getEvmEcdsaSigner = (privateKey: string): PolkadotSigner => {
@ -31,3 +37,16 @@ export const signEcdsa = (
return result;
};
export const createPapiConnectors = (
wsUrl?: string
): { client: PolkadotClient; typedApi: DataHavenApi } => {
const url = wsUrl ?? "ws://127.0.0.1:9944";
const client = createClient(withPolkadotSdkCompat(getWsProvider(url)));
return { client, typedApi: client.getTypedApi(datahaven) };
};
export const getPapiSigner = (person: keyof typeof SUBSTRATE_FUNDED_ACCOUNTS = "ALITH") =>
getEvmEcdsaSigner(SUBSTRATE_FUNDED_ACCOUNTS[person].privateKey);
export type DataHavenApi = Prettify<TypedApi<typeof datahaven>>;

View file

@ -6,7 +6,12 @@ export type LogLevel = "info" | "debug" | "error" | "warn";
export const runShellCommandWithLogger = async (
command: string,
options?: { cwd?: string; env?: object; logLevel?: LogLevel }
options?: {
cwd?: string;
env?: object;
logLevel?: LogLevel;
waitFor?: (...args: unknown[]) => Promise<void>;
}
) => {
const { cwd = ".", env = {}, logLevel = "info" as LogLevel } = options || {};
@ -56,6 +61,10 @@ export const runShellCommandWithLogger = async (
readStream(stderrReader, "stderr", "error")
]);
if (options?.waitFor) {
await options.waitFor();
}
await proc.exited;
} catch (err) {
logger.error("❌ Error running shell command:", command, "in", cwd);

View file

@ -219,3 +219,9 @@ const hexToUint8Array = (hex: string): Uint8Array => {
}
return Buffer.from(hexString, "hex");
};
// This squashes together the properties of the input type T
// making it easier to view in an IDE
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};