mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
test: Update validator set e2e test (#126)
## Add E2E validator-set update flow - feat: `test/utils/validators.ts` for on-demand validator orchestration. - feat: `test/suites/validator-set-update.test.ts` covering allowlist → register → update. - some minor launcher updates: avoid docker cache, add `--platform` when building datahaven image, avoid sending validator-set update on launch. - Helpers: ABI shortcut in `test/utils/contracts.ts`; config tweaks in `test/configs/validator-set.json`. - Minor cleanup/formatting across `test/launcher/*`, `test/scripts/setup-validators.ts`, and related tests. - added `keepAlive` flag to `BaseTestSuite`, in order to avoid tearing down the network while debugging. Defaults, obviously, to false. - added a `failOnTomeout` option on to waitForDataHavenEvents() so the test fails of the timeout is reached and no event was captured. ### Coverage - The test simulates an scenario in which we have two active authorities (alice and bob), which are running, and registered as operators, which is the normal state after the chain launches. Then: - It launches two more nodes (charlie and dave) - It add the nodes to allowlist and register them as operators - It sends the validator set update message - Checks that the validator update message was propagated through the gateway and arrived the external-validators pallet - Checks that the chain continues producing blocks ### Notes The last test case has a timeout of 10 minutes. This is to respect propagation times of the message through the relayers. We are testing that the external validators pallet actually updated the validator set. Locally, I could expect 5~6 minutes, I just wanted to be on the safe side. CI is passing showing that this was enough indeed. --------- Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com> Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com>
This commit is contained in:
parent
48442258ab
commit
dc0f0673e2
19 changed files with 1181 additions and 71 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,3 +35,4 @@ tmp/*
|
|||
.claude/
|
||||
CLAUDE.local.md
|
||||
Agents.md
|
||||
.cursor/
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return await new Promise<void>((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question("\nPress Enter to teardown and cleanup... ", () => {
|
||||
rl.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<voi
|
|||
rpcUrl: options.rpcUrl
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the owner account for validator operations.
|
||||
*/
|
||||
export function getOwnerAccount() {
|
||||
return privateKeyToAccount(ANVIL_FUNDED_ACCOUNTS[6].privateKey as `0x${string}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single operator in EigenLayer and for operator sets.
|
||||
*
|
||||
* @param validatorName - The name of the validator to register
|
||||
* @param options - Extended validator options including connectors and deployments
|
||||
* @throws {Error} If registration transactions fail
|
||||
*/
|
||||
export async function registerSingleOperator(
|
||||
validatorName: TestAccounts,
|
||||
options: ValidatorOptionsExt
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
389
test/suites/validator-set-update.test.ts
Normal file
389
test/suites/validator-set-update.test.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
|
@ -146,3 +146,8 @@ export const getContractInstance = async <C extends ContractName>(
|
|||
client
|
||||
});
|
||||
};
|
||||
|
||||
export const getAbi = async (contract: string) => {
|
||||
const contractInstance = await getContractInstance(contract as ContractName);
|
||||
return contractInstance.abi;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export interface WaitForDataHavenEventOptions<T = unknown> {
|
|||
timeout?: number;
|
||||
/** Callback for matched event */
|
||||
onEvent?: (event: T) => void;
|
||||
/** Callback for timeout */
|
||||
failOnTimeout?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,7 +55,15 @@ export interface WaitForDataHavenEventOptions<T = unknown> {
|
|||
export async function waitForDataHavenEvent<T = unknown>(
|
||||
options: WaitForDataHavenEventOptions<T>
|
||||
): Promise<DataHavenEventResult<T>> {
|
||||
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<T = unknown>(
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ export * from "./parameters";
|
|||
export * from "./parser";
|
||||
export * from "./rpc";
|
||||
export * from "./shell";
|
||||
export * from "./validators";
|
||||
export * from "./viem";
|
||||
|
|
|
|||
165
test/utils/storage.ts
Normal file
165
test/utils/storage.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
/** 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<T = unknown> {
|
||||
/** 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<T = unknown>(
|
||||
options: WaitForDataHavenStorageOptions<T>
|
||||
): Promise<DataHavenStorageResult<T>> {
|
||||
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<T = unknown>(
|
||||
options: WaitForDataHavenStorageOptions<T> & {
|
||||
/** Items that should be contained in the storage value */
|
||||
contains: T[];
|
||||
}
|
||||
): Promise<DataHavenStorageResult<T>> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
244
test/utils/validators.ts
Normal file
244
test/utils/validators.ts
Normal file
|
|
@ -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<boolean> => {
|
||||
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<LaunchedValidatorInfo> => {
|
||||
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<ValidatorInfo>;
|
||||
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`);
|
||||
};
|
||||
Loading…
Reference in a new issue