diff --git a/.gitignore b/.gitignore index cab92e0a..bfb0676b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ tmp/* .claude/ CLAUDE.local.md Agents.md +.cursor/ \ No newline at end of file diff --git a/test/README.md b/test/README.md index 659261b0..ae428f28 100644 --- a/test/README.md +++ b/test/README.md @@ -10,10 +10,29 @@ Quick start guide for running DataHaven end-to-end tests. For comprehensive docu - [Foundry](https://getfoundry.sh/introduction/installation/): To deploy contracts - [Helm](https://helm.sh/docs/intro/install/): The Kubernetes Package Manager -##### MacOS +#### MacOS +If you are running this on a Mac, `zig` is a pre-requisite for crossbuilding the node. Instructions for installation can be found [here](https://ziglang.org/learn/getting-started/). +You may also need to install `libpq` for PostgreSQL connectivity and set the appropriate Rust flags. -> [!IMPORTANT] -> If you are running this on a Mac, `zig` is a pre-requisite for crossbuilding the node. Instructions for installation can be found [here](https://ziglang.org/learn/getting-started/). +```bash +# Install libpq using Homebrew +brew install zig + +# Install libpq using Homebrew +brew install libpq + +# Set environment variables for Rust compilation +export PKG_CONFIG_PATH="/opt/homebrew/opt/libpq/lib/pkgconfig" +export CPPFLAGS="-I$(brew --prefix libpq)/include" +export LDFLAGS="-L$(brew --prefix libpq)/lib" +export PKG_CONFIG_PATH="$(brew --prefix libpq)/lib/pkgconfig" + +# Add to your shell profile (~/.zshrc or ~/.bash_profile) to persist +echo 'export PKG_CONFIG_PATH="/opt/homebrew/opt/libpq/lib/pkgconfig"' >> ~/.zshrc +echo 'export CPPFLAGS="-I$(brew --prefix libpq)/include"' >> ~/.zshrc +echo 'export LDFLAGS="-L$(brew --prefix libpq)/lib"' >> ~/.zshrc +echo 'export PKG_CONFIG_PATH="$(brew --prefix libpq)/lib/pkgconfig"' >> ~/.zshrc +``` ## Quick Start diff --git a/test/cli/handlers/launch/index.ts b/test/cli/handlers/launch/index.ts index 9f57ba9b..307fcabf 100644 --- a/test/cli/handlers/launch/index.ts +++ b/test/cli/handlers/launch/index.ts @@ -11,7 +11,7 @@ import { launchKurtosis } from "./kurtosis"; import { setParametersFromCollection } from "./parameters"; import { launchRelayers } from "./relayer"; import { performSummaryOperations } from "./summary"; -import { performValidatorOperations, performValidatorSetUpdate } from "./validator"; +import { performValidatorOperations } from "./validator"; export const NETWORK_ID = "cli-launch"; @@ -41,7 +41,6 @@ export interface LaunchOptions { deployContracts?: boolean; fundValidators?: boolean; setupValidators?: boolean; - updateValidatorSet?: boolean; setParameters?: boolean; relayer?: boolean; relayerImageTag: string; @@ -95,8 +94,6 @@ const launchFunction = async (options: LaunchOptions, launchedNetwork: LaunchedN await launchRelayers(options, launchedNetwork); - await performValidatorSetUpdate(options, launchedNetwork.elRpcUrl, contractsDeployed); - await performSummaryOperations(options, launchedNetwork); const fullEnd = performance.now(); const fullMinutes = ((fullEnd - timeStart) / (1000 * 60)).toFixed(1); @@ -122,8 +119,7 @@ export const launchPreActionHook = ( buildDatahaven, launchKurtosis, relayer, - setParameters, - updateValidatorSet + setParameters } = thisCmd.opts(); // Check for conflicts with --all flag @@ -135,7 +131,6 @@ export const launchPreActionHook = ( deployContracts === false || fundValidators === false || setupValidators === false || - updateValidatorSet === false || setParameters === false || relayer === false) ) { @@ -152,7 +147,6 @@ export const launchPreActionHook = ( thisCmd.setOptionValue("deployContracts", true); thisCmd.setOptionValue("fundValidators", true); thisCmd.setOptionValue("setupValidators", true); - thisCmd.setOptionValue("updateValidatorSet", true); thisCmd.setOptionValue("setParameters", true); thisCmd.setOptionValue("relayer", true); thisCmd.setOptionValue("cleanNetwork", true); diff --git a/test/cli/handlers/launch/validator.ts b/test/cli/handlers/launch/validator.ts index 3057850b..bcfa071a 100644 --- a/test/cli/handlers/launch/validator.ts +++ b/test/cli/handlers/launch/validator.ts @@ -75,37 +75,17 @@ export const performValidatorOperations = async ( * @returns Promise resolving when the operation is complete */ export const performValidatorSetUpdate = async ( - options: LaunchOptions, networkRpcUrl: string, contractsDeployed: boolean ) => { printHeader("Updating DataHaven Validator Set"); - // If not specified, prompt for update - let shouldUpdateValidatorSet = options.updateValidatorSet; - if (shouldUpdateValidatorSet === undefined) { - shouldUpdateValidatorSet = await confirmWithTimeout( - "Do you want to update the validator set on the substrate chain?", - true, - 10 - ); - } else { - logger.info( - `๐Ÿณ๏ธ Using flag option: ${shouldUpdateValidatorSet ? "will update" : "will not update"} validator set` + if (!contractsDeployed) { + logger.warn( + "โš ๏ธ Updating validator set but contracts were not deployed in this CLI run. Could have unexpected results." ); } - if (shouldUpdateValidatorSet) { - if (!contractsDeployed) { - logger.warn( - "โš ๏ธ Updating validator set but contracts were not deployed in this CLI run. Could have unexpected results." - ); - } - - await updateValidatorSet({ rpcUrl: networkRpcUrl }); - printDivider(); - } else { - logger.info("๐Ÿ‘ Skipping validator set update"); - printDivider(); - } + await updateValidatorSet({ rpcUrl: networkRpcUrl }); + printDivider(); }; diff --git a/test/configs/validator-set.json b/test/configs/validator-set.json index 9741da52..ee0944ca 100644 --- a/test/configs/validator-set.json +++ b/test/configs/validator-set.json @@ -3,27 +3,37 @@ { "publicKey": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "privateKey": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "solochainAddress": "0xE04CC55ebEE1cBCE552f250e85c57B70B2E2625b" + "solochainAddress": "0xE04CC55ebEE1cBCE552f250e85c57B70B2E2625b", + "solochainPrivateKey": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + "solochainAuthorityName": "alice" }, { "publicKey": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "privateKey": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", - "solochainAddress": "0x25451A4de12dcCc2D166922fA938E900fCc4ED24" + "solochainAddress": "0x25451A4de12dcCc2D166922fA938E900fCc4ED24", + "solochainPrivateKey": "0x8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b", + "solochainAuthorityName": "bob" }, { "publicKey": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", "privateKey": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", - "solochainAddress": "0x9e250513a9f2f287d0cdd636dac97b2405098bd5" + "solochainAddress": "0x9e250513a9f2f287d0cdd636dac97b2405098bd5", + "solochainPrivateKey": "0x0b6e18cafb6ed99687ec547bd28139cafdd2bffe70e6b688025de6b445aa5c5b", + "solochainAuthorityName": "charlie" }, { "publicKey": "0x90F79bf6EB2c4f870365E785982E1f101E93b906", "privateKey": "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", - "solochainAddress": "0x6babb2c13fa50cbae5c7d256ff4e3064e3110dab" + "solochainAddress": "0x6babb2c13fa50cbae5c7d256ff4e3064e3110dab", + "solochainPrivateKey": "0x39539ab1876910bbf3a223d84a29e28f1cb4e2e456503e7e91ed39b2e7223d68", + "solochainAuthorityName": "dave" }, { "publicKey": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", "privateKey": "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", - "solochainAddress": "0x20e02a8ec521095e82b0cafa8ac5b22854eec7e8" + "solochainAddress": "0x20e02a8ec521095e82b0cafa8ac5b22854eec7e8", + "solochainPrivateKey": "0x7dce9bc8babb68fec1409be38c8e1a52650206a7ed90ff956ae8a6d15eeaaef4", + "solochainAuthorityName": "eve" } ], "notes": "This validator set maps the first five anvil funded addresses with the Ethereum-compatible addresses of Substrate validators. Check conversion in compressedPubKeyToEthereumAddress() in cli/handlers/common/datahaven.ts" diff --git a/test/framework/suite.ts b/test/framework/suite.ts index 900bbde6..73ba7501 100644 --- a/test/framework/suite.ts +++ b/test/framework/suite.ts @@ -1,4 +1,6 @@ import { afterAll, beforeAll } from "bun:test"; +import readline from "node:readline"; +import { isCI } from "launcher/network"; import { logger } from "utils"; import { launchNetwork } from "../launcher"; import type { LaunchNetworkResult } from "../launcher/types"; @@ -6,14 +8,23 @@ import { ConnectorFactory, type TestConnectors } from "./connectors"; import { TestSuiteManager } from "./manager"; export interface TestSuiteOptions { + /** Unique name for the test suite */ suiteName: string; + /** Network configuration options */ networkOptions?: { + /** Slot time in milliseconds for the network */ slotTime?: number; + /** Enable Blockscout explorer for the network */ blockscout?: boolean; + /** Build DataHaven runtime from source, needed to reflect local changes */ buildDatahaven?: boolean; + /** Docker image tag for DataHaven node */ datahavenImageTag?: string; + /** Docker image tag for Snowbridge relayer */ relayerImageTag?: string; }; + /** Keep network running after tests complete for debugging */ + keepAlive?: boolean; } export abstract class BaseTestSuite { @@ -70,6 +81,11 @@ export abstract class BaseTestSuite { logger.info(`๐Ÿงน Tearing down test suite: ${this.options.suiteName}`); try { + if (this.options.keepAlive && !isCI) { + this.printNetworkInfo(); + await this.waitForEnter(); + } + // Allow derived classes to perform cleanup await this.onTeardown(); @@ -137,4 +153,40 @@ export abstract class BaseTestSuite { } return this.connectorFactory; } + + private printNetworkInfo(): void { + try { + const connectors = this.getConnectors(); + const ln = connectors.launchedNetwork; + logger.info("๐Ÿ›  Keep-alive mode enabled. Network will remain running until you press Enter."); + logger.info("๐Ÿ“ก Network info:"); + logger.info(` โ€ข Network ID: ${ln.networkId}`); + logger.info(` โ€ข Network Name: ${ln.networkName}`); + logger.info(` โ€ข DataHaven RPC: ${connectors.dataHavenRpcUrl}`); + logger.info(` โ€ข Ethereum RPC: ${connectors.ethereumRpcUrl}`); + logger.info(` โ€ข Ethereum CL: ${connectors.ethereumClEndpoint}`); + const containers = ln.containers || []; + if (containers.length > 0) { + logger.info(" โ€ข Containers:"); + for (const c of containers) { + const pubPorts = Object.entries(c.publicPorts || {}) + .map(([k, v]) => `${k}:${v}`) + .join(", "); + logger.info(` - ${c.name} [${pubPorts}]`); + } + } + } catch (e) { + logger.warn("Could not print network info", e as Error); + } + } + + private async waitForEnter(): Promise { + return await new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question("\nPress Enter to teardown and cleanup... ", () => { + rl.close(); + resolve(); + }); + }); + } } diff --git a/test/launcher/datahaven.ts b/test/launcher/datahaven.ts index c7c6274d..81e4a818 100644 --- a/test/launcher/datahaven.ts +++ b/test/launcher/datahaven.ts @@ -13,6 +13,7 @@ import { waitForContainerToStart } from "utils"; import { DEFAULT_SUBSTRATE_WS_PORT } from "utils/constants"; +import { COMMON_LAUNCH_ARGS } from "utils/validators"; import { waitFor } from "utils/waits"; import { type Hex, keccak256, toHex } from "viem"; import { publicKeyToAddress } from "viem/accounts"; @@ -93,19 +94,6 @@ export const launchLocalDataHavenSolochain = async ( } await checkTagExists(options.datahavenImageTag); - const COMMON_LAUNCH_ARGS = [ - "--unsafe-force-node-key-generation", - "--tmp", - "--validator", - "--discover-local", - "--no-prometheus", - "--unsafe-rpc-external", - "--rpc-cors=all", - "--force-authoring", - "--no-telemetry", - "--enable-offchain-indexing=true" - ]; - // Create a unique Docker network name using the network ID const dockerNetworkName = `datahaven-${options.networkId}`; @@ -113,6 +101,7 @@ export const launchLocalDataHavenSolochain = async ( logger.debug(await $`docker network rm ${dockerNetworkName} -f`.text()); logger.debug(await $`docker network create ${dockerNetworkName}`.text()); launchedNetwork.networkName = dockerNetworkName; + launchedNetwork.networkId = options.networkId; logger.success(`DataHaven nodes will use Docker network: ${dockerNetworkName}`); diff --git a/test/launcher/network/index.ts b/test/launcher/network/index.ts index cf5e8f44..7ead1084 100644 --- a/test/launcher/network/index.ts +++ b/test/launcher/network/index.ts @@ -10,7 +10,7 @@ import type { LaunchNetworkResult, NetworkLaunchOptions } from "../types"; import { LaunchedNetwork } from "../types/launchedNetwork"; import { checkBaseDependencies } from "../utils"; import { COMPONENTS } from "../utils/constants"; -import { fundValidators, setupValidators, updateValidatorSet } from "../validators"; +import { fundValidators, setupValidators } from "../validators"; // Authority IDs for test networks const TEST_AUTHORITY_IDS = ["alice", "bob"] as const; @@ -172,7 +172,7 @@ export const launchNetwork = async ( datahavenImageTag: options.datahavenImageTag || "datahavenxyz/datahaven:local", relayerImageTag: options.relayerImageTag || "datahavenxyz/snowbridge-relay:latest", authorityIds: TEST_AUTHORITY_IDS, - buildDatahaven: options.buildDatahaven ?? true, + buildDatahaven: options.buildDatahaven ?? !isCI, // if not specified, default to false for CI, true for local testing datahavenBuildExtraArgs: options.datahavenBuildExtraArgs || "--features=fast-runtime" }, launchedNetwork @@ -244,12 +244,6 @@ export const launchNetwork = async ( launchedNetwork ); - // 8. Update validator set (after relayers are running) - logger.info("๐Ÿ”„ Updating validator set..."); - await updateValidatorSet({ - rpcUrl: launchedNetwork.elRpcUrl - }); - // Log success const endTime = performance.now(); const minutes = ((endTime - startTime) / (1000 * 60)).toFixed(1); @@ -282,3 +276,5 @@ export const launchNetwork = async ( throw error; } }; + +export const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; diff --git a/test/launcher/types/launchedNetwork.ts b/test/launcher/types/launchedNetwork.ts index 71398f16..1b9ad65f 100644 --- a/test/launcher/types/launchedNetwork.ts +++ b/test/launcher/types/launchedNetwork.ts @@ -14,6 +14,7 @@ type ContainerSpec = { export class LaunchedNetwork { protected runId: string; protected _containers: ContainerSpec[]; + protected _networkId: string; protected _networkName: string; protected _activeRelayers: RelayerType[]; /** The RPC URL for the Ethereum Execution Layer (EL) client. */ @@ -30,6 +31,7 @@ export class LaunchedNetwork { this._containers = []; this._activeRelayers = []; this._networkName = ""; + this._networkId = ""; this._elRpcUrl = undefined; this._clEndpoint = undefined; this._kubeNamespace = undefined; @@ -45,6 +47,15 @@ export class LaunchedNetwork { return this._networkName; } + public set networkId(id: string) { + invariant(id.trim().length > 0, "โŒ networkId cannot be empty"); + this._networkId = id.trim(); + } + + public get networkId(): string { + return this._networkId; + } + /** * Gets the unique ID for this run of the launched network. * @returns The run ID string. diff --git a/test/launcher/validators.ts b/test/launcher/validators.ts index 4d2c6ce8..680f5a5a 100644 --- a/test/launcher/validators.ts +++ b/test/launcher/validators.ts @@ -1,7 +1,20 @@ +import { + allocationManagerAbi, + dataHavenServiceManagerAbi, + delegationManagerAbi +} from "contract-bindings"; +import type { TestConnectors } from "framework"; import { fundValidators as fundValidatorsScript } from "scripts/fund-validators"; import { setupValidators as setupValidatorsScript } from "scripts/setup-validators"; import { updateValidatorSet as updateValidatorSetScript } from "scripts/update-validator-set"; -import { logger } from "utils"; +import { + ANVIL_FUNDED_ACCOUNTS, + type Deployments, + getValidatorInfoByName, + logger, + type TestAccounts +} from "utils"; +import { privateKeyToAccount } from "viem/accounts"; /** * Configuration options for validator operations. @@ -10,6 +23,11 @@ export interface ValidatorOptions { rpcUrl: string; } +export interface ValidatorOptionsExt extends ValidatorOptions { + connectors: TestConnectors; + deployments: Deployments; +} + /** * Funds validators with tokens and ETH. * @@ -79,3 +97,193 @@ export const updateValidatorSet = async (options: ValidatorOptions): Promise { + const { connectors, deployments } = options; + const validator = getValidatorInfoByName( + await Bun.file("./configs/validator-set.json").json(), + validatorName + ); + + logger.info(`๐Ÿ”ง Registering ${validator.publicKey} as operator...`); + + try { + const operatorHash = await connectors.walletClient.writeContract({ + address: deployments.DelegationManager as `0x${string}`, + abi: delegationManagerAbi, + functionName: "registerAsOperator", + args: [ + "0x0000000000000000000000000000000000000000", // initDelegationApprover (no approver) + 0, // allocationDelay + "" // metadataURI + ], + account: privateKeyToAccount(validator.privateKey as `0x${string}`), + chain: null + }); + + const operatorReceipt = await connectors.publicClient.waitForTransactionReceipt({ + hash: operatorHash + }); + if (operatorReceipt.status !== "success") { + throw new Error( + `EigenLayer operator registration failed with status: ${operatorReceipt.status}` + ); + } + logger.success(`Registered ${validator.publicKey} as EigenLayer operator`); + + logger.info(`๐Ÿ”ง Registering ${validator.publicKey} for operator sets...`); + const hash = await connectors.walletClient.writeContract({ + address: deployments.AllocationManager as `0x${string}`, + abi: allocationManagerAbi, + functionName: "registerForOperatorSets", + args: [ + validator.publicKey as `0x${string}`, + { + avs: deployments.ServiceManager as `0x${string}`, + operatorSetIds: [0], + data: validator.solochainAddress as `0x${string}` + } + ], + account: privateKeyToAccount(validator.privateKey as `0x${string}`), + chain: null + }); + + logger.info(`๐Ÿ“ Transaction hash for operator set registration: ${hash}`); + + const receipt = await connectors.publicClient.waitForTransactionReceipt({ hash }); + logger.info( + `๐Ÿ“‹ Operator set registration receipt: status=${receipt.status}, gasUsed=${receipt.gasUsed}` + ); + + if (receipt.status === "success") { + logger.success(`Registered ${validator.publicKey} for operator sets`); + } + } catch (error) { + logger.warn(`Failed to register ${validator.publicKey} for operator sets: ${error}`); + throw error; + } +} + +/** + * Checks if the service manager has the specified operator. + * + * @param validatorName - The name of the validator to check + * @param options - Extended validator options including connectors and deployments + * @returns Promise resolving to true if the operator exists + */ +export async function serviceManagerHasOperator( + validatorName: TestAccounts, + options: ValidatorOptionsExt +): Promise { + const { connectors, deployments } = options; + const validator = getValidatorInfoByName( + await Bun.file("./configs/validator-set.json").json(), + validatorName + ); + + const validatorEthAddressToSolochainAddress = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "validatorEthAddressToSolochainAddress", + args: [validator.publicKey as `0x${string}`] + }); + + return ( + validatorEthAddressToSolochainAddress.toLowerCase() === validator.solochainAddress.toLowerCase() + ); +} + +/** + * Adds a validator to the allowlist. + * + * @param validatorName - The name of the validator to add + * @param options - Extended validator options including connectors and deployments + * @throws {Error} If the allowlist transaction fails + */ +export async function addValidatorToAllowlist( + validatorName: TestAccounts, + options: ValidatorOptionsExt +): Promise { + const { connectors, deployments } = options; + const validator = getValidatorInfoByName( + await Bun.file("./configs/validator-set.json").json(), + validatorName + ); + + logger.info(`๐Ÿ”ง Adding ${validatorName} (${validator.publicKey}) to allowlist...`); + + try { + const hash = await connectors.walletClient.writeContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "addValidatorToAllowlist", + args: [validator.publicKey as `0x${string}`], + account: getOwnerAccount(), + chain: null + }); + + logger.info(`๐Ÿ“ Transaction hash for allowlist: ${hash}`); + + const receipt = await connectors.publicClient.waitForTransactionReceipt({ hash }); + logger.info( + `๐Ÿ“‹ Allowlist transaction receipt: status=${receipt.status}, gasUsed=${receipt.gasUsed}` + ); + + if (receipt.status === "success") { + logger.success(`Added ${validator.publicKey} to allowlist`); + } else { + logger.error(`Failed to add ${validator.publicKey} to allowlist`); + throw new Error(`Transaction failed with status: ${receipt.status}`); + } + } catch (error) { + logger.error(`Error adding ${validatorName} to allowlist: ${error}`); + throw error; + } +} + +/** + * Checks if a validator is in the allowlist. + * + * @param validatorName - The name of the validator to check + * @param options - Extended validator options including connectors and deployments + * @returns Promise resolving to true if the validator is allowlisted + */ +export async function isValidatorInAllowlist( + validatorName: TestAccounts, + options: ValidatorOptionsExt +): Promise { + const { connectors, deployments } = options; + const validator = getValidatorInfoByName( + await Bun.file("./configs/validator-set.json").json(), + validatorName + ); + + logger.info(`๐Ÿ” Checking allowlist status for ${validatorName} (${validator.publicKey})...`); + + const isAllowlisted = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "validatorsAllowlist", + args: [validator.publicKey as `0x${string}`] + }); + + logger.info(`๐Ÿ“‹ Allowlist status for ${validatorName}: ${isAllowlisted}`); + return isAllowlisted; +} diff --git a/test/package.json b/test/package.json index 063e801a..6cde60b4 100644 --- a/test/package.json +++ b/test/package.json @@ -7,7 +7,7 @@ "cli": "bun run cli/index.ts", "fmt": "biome check .", "fmt:fix": "biome check --write .", - "build:docker:operator": "docker build -t datahavenxyz/datahaven:local -f ./docker/datahaven-node-local.dockerfile ../.", + "build:docker:operator": "docker build --no-cache --platform linux/amd64 -t datahavenxyz/datahaven:local -f ./docker/datahaven-node-local.dockerfile ../.", "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", diff --git a/test/scripts/setup-validators.ts b/test/scripts/setup-validators.ts index 9acf5bad..98d8211b 100644 --- a/test/scripts/setup-validators.ts +++ b/test/scripts/setup-validators.ts @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import invariant from "tiny-invariant"; -import { logger, runShellCommandWithLogger } from "../utils/index"; +import { + getValidatorInfoByName, + logger, + runShellCommandWithLogger, + TestAccounts +} from "../utils/index"; interface SetupValidatorsOptions { rpcUrl: string; @@ -18,7 +23,9 @@ interface ValidatorConfig { validators: { publicKey: string; privateKey: string; - solochainAddress?: string; // Optional substrate address + solochainAddress?: string; + solochainPrivateKey?: string; + solochainAuthorityName: string; }[]; notes?: string; } @@ -93,10 +100,14 @@ export const setupValidators = async (options: SetupValidatorsOptions): Promise< } } - const validators = config.validators; - logger.info(`๐Ÿ”Ž Found ${validators.length} validators to register`); + const validators = [ + getValidatorInfoByName(config, TestAccounts.Alice), + getValidatorInfoByName(config, TestAccounts.Bob) + ]; - // Iterate through all validators to register them + logger.info(`๐Ÿ”Ž Registering ${validators.length} validators`); + + // Iterate through validators to register them for (let i = 0; i < validators.length; i++) { const validator = validators[i]; logger.info(`๐Ÿ”ง Setting up validator ${i} (${validator.publicKey})`); diff --git a/test/suites/datahaven-substrate.test.ts b/test/suites/datahaven-substrate.test.ts index 7d510f14..85040065 100644 --- a/test/suites/datahaven-substrate.test.ts +++ b/test/suites/datahaven-substrate.test.ts @@ -1,6 +1,13 @@ import { beforeAll, describe, expect, it } from "bun:test"; import type { PolkadotSigner } from "polkadot-api"; -import { getPapiSigner, logger, SUBSTRATE_FUNDED_ACCOUNTS } from "utils"; +import { + getPapiSigner, + isValidatorNodeRunning, + launchDatahavenValidator, + logger, + SUBSTRATE_FUNDED_ACCOUNTS, + TestAccounts +} from "utils"; import { isAddress } from "viem"; import { BaseTestSuite } from "../framework"; @@ -12,6 +19,16 @@ class DataHavenSubstrateTestSuite extends BaseTestSuite { this.setupHooks(); } + + override async onSetup(): Promise { + await launchDatahavenValidator(TestAccounts.Charlie, { + launchedNetwork: this.getConnectors().launchedNetwork + }); + } + + public getNetworkId(): string { + return this.getConnectors().launchedNetwork.networkId; + } } // Create the test suite instance @@ -68,4 +85,9 @@ describe("DataHaven Substrate Operations", () => { logger.info(`Current block #${blockHeader.number}`); }); + + it("should see Charlie running", async () => { + const isRunning = await isValidatorNodeRunning(TestAccounts.Charlie, suite.getNetworkId()); + expect(isRunning).toBe(true); + }); }); diff --git a/test/suites/validator-set-update.test.ts b/test/suites/validator-set-update.test.ts new file mode 100644 index 00000000..74f98698 --- /dev/null +++ b/test/suites/validator-set-update.test.ts @@ -0,0 +1,389 @@ +/** + * Validator Set Update E2E: Ethereum โ†’ Snowbridge โ†’ DataHaven + * + * Exercises: + * - Start network and ensure 4 validator nodes are running (Alice, Bob, Charlie, Dave). + * - Confirm initial mapping exists only for Alice/Bob on `ServiceManager`. + * - Allowlist and register Charlie/Dave as operators on Ethereum. + * - Send updated validator set via `ServiceManager.sendNewValidatorSet`, assert Gateway `OutboundMessageAccepted`. + * - Observe `ExternalValidators.ExternalValidatorsSet` on DataHaven (substrate), confirming propagation. + */ +import { beforeAll, describe, expect, it } from "bun:test"; +import { + addValidatorToAllowlist, + getOwnerAccount, + isValidatorInAllowlist, + registerSingleOperator, + serviceManagerHasOperator +} from "launcher/validators"; +import { + type Deployments, + getValidatorInfoByName, + isValidatorNodeRunning, + launchDatahavenValidator, + logger, + parseDeploymentsFile, + TestAccounts, + type ValidatorInfo +} from "utils"; +import { waitForDataHavenEvent } from "utils/events"; +import { waitForDataHavenStorageContains } from "utils/storage"; +import { decodeEventLog, parseEther } from "viem"; +import { dataHavenServiceManagerAbi, gatewayAbi } from "../contract-bindings"; +import { BaseTestSuite } from "../framework"; + +class ValidatorSetUpdateTestSuite extends BaseTestSuite { + constructor() { + super({ + suiteName: "validator-set-update", + networkOptions: { + slotTime: 2, + blockscout: false + } + }); + + this.setupHooks(); + } + + override async onSetup(): Promise { + logger.info("Waiting for cross-chain infrastructure to stabilize..."); + + // Launch to new nodes to be authorities + console.log("Launching Charlie..."); + await launchDatahavenValidator(TestAccounts.Charlie, { + launchedNetwork: this.getConnectors().launchedNetwork + }); + + console.log("Launching Dave..."); + await launchDatahavenValidator(TestAccounts.Dave, { + launchedNetwork: this.getConnectors().launchedNetwork + }); + } + + public getNetworkId(): string { + return this.getConnectors().launchedNetwork.networkId; + } + + public getValidatorOptions() { + return { + rpcUrl: this.getConnectors().launchedNetwork.elRpcUrl, + connectors: this.getTestConnectors(), + deployments + }; + } +} + +// Create the test suite instance +const suite = new ValidatorSetUpdateTestSuite(); +let deployments: Deployments; + +describe("Validator Set Update", () => { + // Validator sets loaded from external JSON + let initialValidators: ValidatorInfo[] = []; + let newValidators: ValidatorInfo[] = []; + + beforeAll(async () => { + deployments = await parseDeploymentsFile(); + + // Load validator set from JSON config + const validatorSetPath = "./configs/validator-set.json"; + try { + const validatorSetJson: any = await Bun.file(validatorSetPath).json(); + + initialValidators = [ + getValidatorInfoByName(validatorSetJson, TestAccounts.Alice), + getValidatorInfoByName(validatorSetJson, TestAccounts.Bob) + ]; + + newValidators = [ + getValidatorInfoByName(validatorSetJson, TestAccounts.Charlie), + getValidatorInfoByName(validatorSetJson, TestAccounts.Dave) + ]; + + logger.success("Loaded validator set from JSON file"); + } catch (err) { + logger.error(`Failed to load validator set from ${validatorSetPath}: ${err}`); + throw err; + } + }); + + it("should verify validators are running", async () => { + const isAliceRunning = await isValidatorNodeRunning(TestAccounts.Alice, suite.getNetworkId()); + const isBobRunning = await isValidatorNodeRunning(TestAccounts.Bob, suite.getNetworkId()); + const isCharlieRunning = await isValidatorNodeRunning( + TestAccounts.Charlie, + suite.getNetworkId() + ); + const isDaveRunning = await isValidatorNodeRunning(TestAccounts.Dave, suite.getNetworkId()); + + expect(isAliceRunning).toBe(true); + expect(isBobRunning).toBe(true); + expect(isCharlieRunning).toBe(true); + expect(isDaveRunning).toBe(true); + }); + + it("should verify initial test setup", async () => { + const connectors = suite.getTestConnectors(); + + // Verify Ethereum side connectivity + const ethBlockNumber = await connectors.publicClient.getBlockNumber(); + expect(ethBlockNumber).toBeGreaterThan(0); + logger.success(`Ethereum network connected at block: ${ethBlockNumber}`); + + // Verify DataHaven substrate connectivity + const dhBlockHeader = await connectors.papiClient.getBlockHeader(); + expect(dhBlockHeader.number).toBeGreaterThan(0); + logger.success(`DataHaven substrate connected at block: ${dhBlockHeader.number}`); + + // Verify contract deployments + expect(deployments.ServiceManager).toBeDefined(); + logger.success(`ServiceManager deployed at: ${deployments.ServiceManager}`); + }); + + it("should verify initial validator set state", async () => { + const connectors = suite.getTestConnectors(); + + logger.info("๐Ÿ” Verifying initial validator set state..."); + + // Check that only initial validators have mappings set + for (const validator of initialValidators) { + const solochainAddress = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "validatorEthAddressToSolochainAddress", + args: [validator.publicKey as `0x${string}`] + }); + + expect(solochainAddress.toLowerCase()).toBe(validator.solochainAddress.toLowerCase()); + logger.success(`Validator ${validator.publicKey} mapped to ${solochainAddress}`); + } + }); + + it("should verify new validators are not yet registered", async () => { + const connectors = suite.getTestConnectors(); + + // Verify that new validators are not yet registered + for (const validator of newValidators) { + const solochainAddress = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "validatorEthAddressToSolochainAddress", + args: [validator.publicKey as `0x${string}`] + }); + + expect(solochainAddress).toBe("0x0000000000000000000000000000000000000000"); + logger.success(`Validator ${validator.publicKey} not yet registered (as expected)`); + } + + logger.success("Initial validator set state verified: only Alice and Bob are active"); + }); + + it("should add new validators to allowlist", async () => { + logger.info("๐Ÿ“ค Adding Charlie and Dave to allowlist..."); + + // Add Charlie and Dave to the allowlist + await addValidatorToAllowlist(TestAccounts.Charlie, suite.getValidatorOptions()); + await addValidatorToAllowlist(TestAccounts.Dave, suite.getValidatorOptions()); + + // Verification of allowlist status + logger.info("๐Ÿ” Verification of allowlist status..."); + const charlieAllowlisted = await isValidatorInAllowlist( + TestAccounts.Charlie, + suite.getValidatorOptions() + ); + const daveAllowlisted = await isValidatorInAllowlist( + TestAccounts.Dave, + suite.getValidatorOptions() + ); + + expect(charlieAllowlisted).toBe(true); + expect(daveAllowlisted).toBe(true); + + logger.success("โœ… Both validators successfully added to allowlist"); + }, 60_000); + + it("should register new validators as operators", async () => { + logger.info("๐Ÿ“ค Registering Charlie and Dave as operators..."); + + // Register Charlie and Dave as operators + await registerSingleOperator(TestAccounts.Charlie, suite.getValidatorOptions()); + await registerSingleOperator(TestAccounts.Dave, suite.getValidatorOptions()); + + // Verify both validators are properly registered in ServiceManager + const charlieRegistered = await serviceManagerHasOperator( + TestAccounts.Charlie, + suite.getValidatorOptions() + ); + expect(charlieRegistered).toBe(true); + logger.success("Charlie is registered as operator"); + + const daveRegistered = await serviceManagerHasOperator( + TestAccounts.Dave, + suite.getValidatorOptions() + ); + expect(daveRegistered).toBe(true); + logger.success("Dave is registered as operator"); + }, 60_000); // 1 minute timeout + + it("should send updated validator set to DataHaven", async () => { + const connectors = suite.getTestConnectors(); + + // proceed directly to sending, allowlist/register already covered in previous tests + logger.info("๐Ÿ“ค Sending updated validator set (Charlie, Dave) to DataHaven..."); + + // Build the updated validator set message + // Debug: Check what validators are registered in the ServiceManager contract + logger.info("๐Ÿ” Checking registered validators in DataHavenServiceManager..."); + + // Check all validators (initial + new) + const allValidators = [...initialValidators, ...newValidators]; + for (const validator of allValidators) { + const registeredAddress = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "validatorEthAddressToSolochainAddress", + args: [validator.publicKey as `0x${string}`] + }); + + const isRegistered = registeredAddress !== "0x0000000000000000000000000000000000000000"; + logger.info(` ${validator.publicKey} -> ${registeredAddress} (registered: ${isRegistered})`); + } + + logger.info("๐Ÿ” Building validator set message..."); + const updatedMessageBytes = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "buildNewValidatorSetMessage", + args: [] + }); + + logger.info(`๐Ÿ“Š Updated validator set message size: ${updatedMessageBytes.length} bytes`); + logger.info(`๐Ÿ“Š Message bytes (first 100): ${updatedMessageBytes.slice(0, 100)}`); + + // Verify that new validators are properly registered before sending message + logger.info("๐Ÿ” Verifying new validators are registered before sending message..."); + for (const validator of newValidators) { + const registeredAddress = await connectors.publicClient.readContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "validatorEthAddressToSolochainAddress", + args: [validator.publicKey as `0x${string}`] + }); + + const isRegistered = registeredAddress !== "0x0000000000000000000000000000000000000000"; + if (!isRegistered) { + throw new Error( + `Validator ${validator.publicKey} is not registered in ServiceManager before sending message` + ); + } + logger.success(`${validator.publicKey} is registered -> ${registeredAddress}`); + } + + // Log the expected validators that should be in the message + logger.info("๐Ÿ” Expected validators in message:"); + for (let i = 0; i < newValidators.length; i++) { + logger.info(` Validator ${i}: ${newValidators[i].solochainAddress}`); + } + + // Send the updated validator set + const executionFee = parseEther("0.1"); + const relayerFee = parseEther("0.2"); + const totalValue = parseEther("0.3"); + + logger.info( + `Sending validator set with executionFee=${executionFee}, + relayerFee=${relayerFee}, + totalValue=${totalValue}` + ); + + try { + const hash = await connectors.walletClient.writeContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "sendNewValidatorSet", + args: [executionFee, relayerFee], + value: totalValue, + gas: 1000000n, + account: getOwnerAccount(), + chain: null + }); + + logger.info(`๐Ÿ“ Transaction hash for validator set update: ${hash}`); + + const receipt = await connectors.publicClient.waitForTransactionReceipt({ hash }); + logger.info( + `๐Ÿ“‹ Validator set update receipt: status=${receipt.status}, gasUsed=${receipt.gasUsed}` + ); + + if (receipt.status === "success") { + logger.success(`Transaction sent: ${hash}`); + logger.info(`โ›ฝ Gas used: ${receipt.gasUsed}`); + } else { + logger.error(`Transaction failed with status: ${receipt.status}`); + throw new Error(`Transaction failed with status: ${receipt.status}`); + } + + logger.info("๐Ÿ” Checking for OutboundMessageAccepted event in transaction receipt..."); + + const hasOutboundAccepted = (receipt.logs ?? []).some((log: any) => { + try { + const decoded = decodeEventLog({ abi: gatewayAbi, data: log.data, topics: log.topics }); + return decoded.eventName === "OutboundMessageAccepted"; + } catch { + return false; + } + }); + + if (hasOutboundAccepted) { + logger.success("OutboundMessageAccepted event found in transaction receipt!"); + } else { + throw new Error("OutboundMessageAccepted event not found in transaction receipt"); + } + } catch (error) { + logger.error(`Error sending validator set update: ${error}`); + throw error; + } + }, 300_000); + + it("should verify validator set update on DataHaven substrate", async () => { + const connectors = suite.getTestConnectors(); + + logger.info("๐Ÿ” Verifying validator set on DataHaven substrate chain..."); + + logger.info("โณ Waiting for ExternalValidatorsSet event..."); + const externalValidatorsSetEvent = await waitForDataHavenEvent({ + api: connectors.dhApi, + pallet: "ExternalValidators", + event: "ExternalValidatorsSet", + timeout: 600_000, + failOnTimeout: true + }); + + if (!externalValidatorsSetEvent.data) { + logger.error("ExternalValidatorsSet event not found"); + throw new Error("ExternalValidatorsSet event not found"); + } + logger.success("ExternalValidatorsSet event found"); + + logger.info( + "๐Ÿ” Checking the new validators are present in the ExternalValidators pallet storage..." + ); + + const expectedAddresses = newValidators.map((v) => v.solochainAddress as `0x${string}`); + + const storageResult = await waitForDataHavenStorageContains({ + api: connectors.dhApi, + pallet: "ExternalValidators", + storage: "ExternalValidators", + contains: expectedAddresses, + timeout: 10_000, + failOnTimeout: true + }); + + if (!storageResult.value) { + throw new Error("Failed to get ExternalValidators storage value"); + } + + logger.success("New validators are present in the ExternalValidators pallet storage"); + }, 600_000); +}); diff --git a/test/utils/contracts.ts b/test/utils/contracts.ts index 717b9c70..1301472e 100644 --- a/test/utils/contracts.ts +++ b/test/utils/contracts.ts @@ -146,3 +146,8 @@ export const getContractInstance = async ( client }); }; + +export const getAbi = async (contract: string) => { + const contractInstance = await getContractInstance(contract as ContractName); + return contractInstance.abi; +}; diff --git a/test/utils/events.ts b/test/utils/events.ts index 7318f295..6e2a9206 100644 --- a/test/utils/events.ts +++ b/test/utils/events.ts @@ -43,6 +43,8 @@ export interface WaitForDataHavenEventOptions { timeout?: number; /** Callback for matched event */ onEvent?: (event: T) => void; + /** Callback for timeout */ + failOnTimeout?: boolean; } /** @@ -53,7 +55,15 @@ export interface WaitForDataHavenEventOptions { export async function waitForDataHavenEvent( options: WaitForDataHavenEventOptions ): Promise> { - const { api, pallet, event, filter, timeout: timeoutMs = 30000, onEvent } = options; + const { + api, + pallet, + event, + filter, + timeout: timeoutMs = 30000, + onEvent, + failOnTimeout + } = options; const eventWatcher = (api.event as any)?.[pallet]?.[event]; if (!eventWatcher?.watch) { @@ -88,6 +98,9 @@ export async function waitForDataHavenEvent( timeout({ first: timeoutMs, with: () => { + if (failOnTimeout) { + throw new Error(`Timeout waiting for event ${pallet}.${event} after ${timeoutMs}ms`); + } logger.debug(`Timeout waiting for event ${pallet}.${event} after ${timeoutMs}ms`); return of(null); } diff --git a/test/utils/index.ts b/test/utils/index.ts index 6251563d..f0d76aaf 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -11,4 +11,5 @@ export * from "./parameters"; export * from "./parser"; export * from "./rpc"; export * from "./shell"; +export * from "./validators"; export * from "./viem"; diff --git a/test/utils/storage.ts b/test/utils/storage.ts new file mode 100644 index 00000000..0c165ddb --- /dev/null +++ b/test/utils/storage.ts @@ -0,0 +1,165 @@ +import { firstValueFrom, of } from "rxjs"; +import { catchError, map, filter as rxFilter, take, tap, timeout } from "rxjs/operators"; +import { logger } from "./logger"; +import type { DataHavenApi } from "./papi"; + +/** + * Storage utilities for DataHaven chain + * + * This module provides utilities for waiting for storage changes on DataHaven: + * - Storage value changes (using substrate storage queries) + * - Storage value conditions (waiting for specific values or conditions) + */ + +/** + * Result from waiting for a DataHaven storage change + */ +export interface DataHavenStorageResult { + /** Pallet name */ + pallet: string; + /** Storage name */ + storage: string; + /** Storage value (null if timeout or error) */ + value: T | null; + /** Metadata about when/where storage was updated */ + meta: any | null; +} + +/** + * Options for waiting for a DataHaven storage change + */ +export interface WaitForDataHavenStorageOptions { + /** DataHaven API instance */ + api: DataHavenApi; + /** Pallet name (e.g., "System", "Balances") */ + pallet: string; + /** Storage name (e.g., "Account", "TotalIssuance") */ + storage: string; + /** Optional filter function to match specific storage values */ + filter?: (value: T) => boolean; + /** Timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Callback for matched storage value */ + onValue?: (value: T) => void; + /** Whether to fail on timeout (default: true) */ + failOnTimeout?: boolean; +} + +/** + * Wait for a specific storage value change on the DataHaven chain + * @param options - Options for storage waiting + * @returns Storage result with pallet, storage name, and value + */ +export async function waitForDataHavenStorage( + options: WaitForDataHavenStorageOptions +): Promise> { + const { + api, + pallet, + storage, + filter, + timeout: timeoutMs = 30000, + onValue, + failOnTimeout = true + } = options; + + const storageQuery = (api.query as any)?.[pallet]?.[storage]; + if (!storageQuery?.watchValue) { + logger.warn(`Storage ${pallet}.${storage} not found or doesn't support watchValue`); + return { pallet, storage, value: null, meta: null }; + } + + let meta: any = null; + let value: T | null = null; + + try { + const matched: any = await firstValueFrom( + storageQuery.watchValue().pipe( + // Log every raw emission from the storage watcher + tap((raw: any) => { + logger.debug(`Storage ${pallet}.${storage} changed (raw): ${JSON.stringify(raw)}`); + }), + // Normalize to a consistent shape { payload, meta } + map((raw: any) => ({ payload: raw?.payload ?? raw, meta: raw?.meta ?? null })), + // Apply the optional filter BEFORE taking the first item + rxFilter(({ payload }) => { + if (!filter) return true; + try { + return filter(payload as T); + } catch { + return false; + } + }), + // Stop on the first matching value + take(1), + // Enforce an overall timeout while waiting for a matching value + timeout({ + first: timeoutMs, + with: () => { + if (failOnTimeout) { + throw new Error( + `Timeout waiting for storage ${pallet}.${storage} after ${timeoutMs}ms` + ); + } + logger.debug(`Timeout waiting for storage ${pallet}.${storage} after ${timeoutMs}ms`); + return of(null); + } + }), + catchError((error: unknown) => { + logger.error(`Error in storage subscription ${pallet}.${storage}: ${error}`); + return of(null); + }) + ) + ); + + if (matched) { + meta = matched.meta; + value = matched.payload as T; + if (value !== null && value !== undefined) { + onValue?.(value); + } + } + } catch (error) { + logger.error(`Unexpected error waiting for storage ${pallet}.${storage}: ${error}`); + value = null; + } + + return { pallet, storage, value, meta }; +} + +/** + * Wait for a storage value to contain specific items (useful for arrays/sets) + * @param options - Options for storage waiting with array containment check + * @returns Storage result with pallet, storage name, and value + */ +export async function waitForDataHavenStorageContains( + options: WaitForDataHavenStorageOptions & { + /** Items that should be contained in the storage value */ + contains: T[]; + } +): Promise> { + const { contains, api, pallet, storage, onValue, ...baseOptions } = options; + + const normalizeValue = (item: any): any => { + if (item.toLowerCase) { + return item.toLowerCase(); + } + return item; + }; + + return waitForDataHavenStorage({ + ...baseOptions, + api, + pallet, + storage, + onValue, + filter: (value: T) => { + if (Array.isArray(value)) { + const normalizedValue = value.map(normalizeValue); + const normalizedContains = contains.map(normalizeValue); + return normalizedContains.every((item) => normalizedValue.includes(item)); + } + return false; + } + }); +} diff --git a/test/utils/validators.ts b/test/utils/validators.ts new file mode 100644 index 00000000..ff839a66 --- /dev/null +++ b/test/utils/validators.ts @@ -0,0 +1,244 @@ +/** + * DataHaven utility functions for launching and managing validator nodes + * + * This module provides utilities for launching individual DataHaven validator nodes + * on demand, checking their status, and managing their lifecycle. + * + * @example + * ```typescript + * import { launchDatahavenValidator, TestAccounts } from "utils"; + * + * // Launch a new Charlie validator node + * const charlieNode = await launchDatahavenValidator(TestAccounts.Charlie, { + * launchedNetwork: suite.getLaunchedNetwork() + * }); + * + * console.log(`Charlie node launched on port ${charlieNode.publicPort}`); + * console.log(`WebSocket URL: ${charlieNode.wsUrl}`); + * ``` + * + * @example + * ```typescript + * // Check if a node is already running before launching + * if (await isValidatorNodeRunning("charlie", "test-network")) { + * console.log("Charlie node is already running"); + * } else { + * // Launch the node + * const node = await launchDatahavenValidator(TestAccounts.Charlie, options); + * } + * ``` + */ + +import { $ } from "bun"; +import { dataHavenServiceManagerAbi } from "contract-bindings"; +import { logger, waitForContainerToStart } from "utils"; +import { DEFAULT_SUBSTRATE_WS_PORT } from "utils/constants"; +import { getPublicPort } from "utils/docker"; +import { privateKeyToAccount } from "viem/accounts"; +import type { LaunchedNetwork } from "../launcher/types/launchedNetwork"; + +/** + * Enum for test account names that are prefunded in substrate + */ +export enum TestAccounts { + Alice = "alice", + Bob = "bob", + Charlie = "charlie", + Dave = "dave", + Eve = "eve", + Ferdie = "ferdie" +} + +export interface ValidatorInfo { + publicKey: string; + privateKey: string; + solochainAddress: string; + solochainPrivateKey: string; + solochainAuthorityName: string; + isActive: boolean; +} + +/** + * Information about a launched DataHaven validator node + */ +export interface LaunchedValidatorInfo { + nodeId: string; + containerName: string; + rpcUrl: string; + wsUrl: string; + publicPort: number; + internalPort: number; +} + +/** + * Options for launching a DataHaven validator + */ +export interface LaunchValidatorOptions { + datahavenImageTag?: string; + launchedNetwork: LaunchedNetwork; +} + +export const COMMON_LAUNCH_ARGS = [ + "--unsafe-force-node-key-generation", + "--tmp", + "--validator", + "--discover-local", + "--no-prometheus", + "--unsafe-rpc-external", + "--rpc-cors=all", + "--force-authoring", + "--no-telemetry", + "--enable-offchain-indexing=true" +]; + +/** + * Checks if a DataHaven validator node is already running + * @param nodeId - The node identifier (e.g., "alice", "bob") + * @param networkId - The network identifier + * @returns True if the node is running, false otherwise + */ +export const isValidatorNodeRunning = async ( + nodeId: string, + networkId: string +): Promise => { + const containerName = `datahaven-${nodeId}-${networkId}`; + const dockerPsOutput = await $`docker ps -q --filter "name=^${containerName}"`.text(); + return dockerPsOutput.trim().length > 0; +}; + +/** + * Launches a single DataHaven validator node on demand + * @param name - The test account name to launch + * @param options - Configuration options for launching the node + * @returns Information about the launched node + */ +export const launchDatahavenValidator = async ( + name: TestAccounts, + options: LaunchValidatorOptions +): Promise => { + const nodeId = name.toLowerCase(); + const networkId = options.launchedNetwork.networkId; + const datahavenImageTag = options.datahavenImageTag || "datahavenxyz/datahaven:local"; + const containerName = `datahaven-${nodeId}-${networkId}`; + + // Check if node is already running + if (await isValidatorNodeRunning(nodeId, networkId)) { + logger.warn(`โš ๏ธ Node ${nodeId} is already running in network ${networkId}`); + + // Get existing node info + const publicPort = await getPublicPort(containerName, DEFAULT_SUBSTRATE_WS_PORT); + return { + nodeId, + containerName, + rpcUrl: `http://127.0.0.1:${publicPort}`, + wsUrl: `ws://127.0.0.1:${publicPort}`, + publicPort, + internalPort: DEFAULT_SUBSTRATE_WS_PORT + }; + } + + logger.info(`๐Ÿš€ Launching DataHaven validator node: ${nodeId}...`); + + // Get port mapping for the node + const portMapping = getPortMappingForNode(nodeId, networkId); + + const command: string[] = [ + "docker", + "run", + "-d", + "--name", + containerName, + "--network", + options.launchedNetwork.networkName, + ...portMapping, + datahavenImageTag, + `--${nodeId}`, + ...COMMON_LAUNCH_ARGS + ]; + + logger.debug(await $`sh -c "${command.join(" ")}"`.text()); + + await waitForContainerToStart(containerName); + + // Get the dynamic port and register in the network + const publicPort = await getPublicPort(containerName, DEFAULT_SUBSTRATE_WS_PORT); + + // Add container to the launched network + options.launchedNetwork.addContainer( + containerName, + { ws: publicPort }, + { ws: DEFAULT_SUBSTRATE_WS_PORT } + ); + + logger.success(`DataHaven validator node ${nodeId} launched successfully on port ${publicPort}`); + + return { + nodeId, + containerName, + rpcUrl: `http://127.0.0.1:${publicPort}`, + wsUrl: `ws://127.0.0.1:${publicPort}`, + publicPort, + internalPort: DEFAULT_SUBSTRATE_WS_PORT + }; +}; + +/** + * Determines the port mapping for a DataHaven node based on the network type. + * Reused from launcher/datahaven.ts + * @param nodeId - The node identifier (e.g., "alice", "bob") + * @param networkId - The network identifier + * @returns Array of port mapping arguments for Docker run command + */ +const getPortMappingForNode = (nodeId: string, networkId: string): string[] => { + const isCliLaunch = networkId === "cli-launch"; + + if (isCliLaunch && nodeId === "alice") { + // For CLI-launch networks, only alice gets the fixed port mapping + return ["-p", `${DEFAULT_SUBSTRATE_WS_PORT}:${DEFAULT_SUBSTRATE_WS_PORT}`]; + } + + // For other networks or non-alice nodes, only expose internal port + // Docker will assign a random external port + return ["-p", `${DEFAULT_SUBSTRATE_WS_PORT}`]; +}; + +/** + * Get node info by account name from validator set JSON + * @param validatorSetJson - Validator set JSON + * @param account - Test account name + * @returns Node info + */ +export const getValidatorInfoByName = ( + validatorSetJson: any, + account: TestAccounts +): ValidatorInfo => { + const validatorsRaw = validatorSetJson.validators as Array; + const node = validatorsRaw.find((v) => v.solochainAuthorityName === account.toLowerCase()); + if (!node) { + throw new Error(`Node ${account} not found in validator set`); + } + return node; +}; + +/** + * Adds a validator to the EigenLayer allowlist + * @param connectors - The connectors to use + * @param validator - The validator to add to the allowlist + */ +export const addValidatorToAllowlist = async ( + connectors: any, + validator: ValidatorInfo, + deployments: any +) => { + logger.info(`Adding validator ${validator.publicKey} to allowlist...`); + const hash = await connectors.walletClient.writeContract({ + address: deployments.ServiceManager as `0x${string}`, + abi: dataHavenServiceManagerAbi, + functionName: "addValidatorToAllowlist", + args: [validator.publicKey as `0x${string}`], + account: privateKeyToAccount(validator.privateKey as `0x${string}`), + chain: null + }); + await connectors.publicClient.waitForTransactionReceipt({ hash }); + logger.info(`โœ… Validator ${validator.publicKey} added to allowlist`); +};