mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
## Implement E2E Testing Framework with Isolated Networks ### Summary Refactors the existing E2E testing infrastructure to provide isolated test environments with parallel execution support. Each test suite now runs in its own network namespace, preventing resource conflicts. ### Key Changes - **New Testing Framework** (`test/framework/`): Base classes for test lifecycle management with automatic setup/teardown - **Launcher Module** (`test/launcher/`): Extracted network orchestration logic from CLI handlers for reusability - **Parallel Execution**: Added `test-parallel.ts` script with concurrency limits to prevent resource exhaustion - **Test Isolation**: Each suite gets unique network IDs (format: `suiteName-timestamp`) and Docker networks - **Improved Test Organization**: Migrated tests to new framework, deprecated old test structure ### Test Improvements - Added 4 new test suites demonstrating framework usage. : - `contracts.test.ts` - Smart contract deployment/interaction - `datahaven-substrate.test.ts` - Substrate API operations - `cross-chain.test.ts` - Snowbridge cross-chain messaging - `ethereum-basic.test.ts` - Ethereum network operations > [!WARNING] The test suites themselves are bad and shouldn't be consider examples of good tests. They were AI generated just to test the concurrency of test runners ### Documentation - Added comprehensive framework overview (`E2E_FRAMEWORK_OVERVIEW.md`) - Updated README with parallel testing commands - Added test patterns and best practices ### Breaking Changes - Old test suites moved to `e2e - DEPRECATED/` directory - Test execution now requires extending `BaseTestSuite` class ### Testing Run tests with: `bun test:e2e` or `bun test:e2e:parallel` (with concurrency limits) ### TODO - [ ] Implement good test examples. - [ ] Implement useful test utils (like waiting for an event to show up in DataHaven or Ethereum). - [ ] Enforce tests with CI (currently cannot be done due to intermittent error when sending a transaction with PAPI). --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: undercover-cactus <lola@moonsonglabs.com>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
// Script to fund validators with tokens and ETH for local testing
|
|
import { $ } from "bun";
|
|
import invariant from "tiny-invariant";
|
|
import { logger } from "../utils/index";
|
|
|
|
interface FundValidatorsOptions {
|
|
rpcUrl: string;
|
|
validatorsConfig?: string; // Path to JSON config file with validator addresses
|
|
networkName?: string; // Network name for default deployment path
|
|
deploymentPath?: string; // Optional custom deployment path
|
|
}
|
|
|
|
/**
|
|
* JSON structure for validator configuration
|
|
*/
|
|
interface ValidatorConfig {
|
|
validators: {
|
|
publicKey: string;
|
|
privateKey: string;
|
|
solochainAddress?: string; // Optional substrate address
|
|
}[];
|
|
notes?: string;
|
|
}
|
|
|
|
/**
|
|
* Structure for strategy information in the deployment file
|
|
*/
|
|
interface StrategyInfo {
|
|
address: string;
|
|
underlyingToken: string;
|
|
tokenCreator: string;
|
|
}
|
|
|
|
/**
|
|
* Deployment file structure with enhanced strategy information
|
|
*/
|
|
interface DeploymentInfo {
|
|
network: string;
|
|
DeployedStrategies: StrategyInfo[];
|
|
}
|
|
|
|
/**
|
|
* Funds validators with tokens and ETH for local testing
|
|
*
|
|
* @param options - Configuration options for funding
|
|
* @param options.rpcUrl - The RPC URL to connect to
|
|
* @param options.validatorsConfig - Path to JSON config file (uses default config if not provided)
|
|
* @returns Promise resolving to true if validators were funded successfully
|
|
*/
|
|
export const fundValidators = async (options: FundValidatorsOptions): Promise<boolean> => {
|
|
const { rpcUrl, validatorsConfig, networkName = "anvil", deploymentPath } = options;
|
|
|
|
// Validate RPC URL
|
|
invariant(rpcUrl, "❌ RPC URL is required");
|
|
|
|
// Load validator configuration - use default path if not specified
|
|
const configPath = validatorsConfig || path.resolve(__dirname, "../configs/validator-set.json");
|
|
|
|
// Ensure the configuration file exists
|
|
if (!fs.existsSync(configPath)) {
|
|
logger.error(`Validator configuration file not found: ${configPath}`);
|
|
throw new Error("Validator configuration file is required");
|
|
}
|
|
|
|
// Load and validate the validator configuration
|
|
logger.debug(`Loading validator configuration from ${configPath}`);
|
|
let config: ValidatorConfig;
|
|
|
|
try {
|
|
const fileContent = fs.readFileSync(configPath, "utf8");
|
|
config = JSON.parse(fileContent);
|
|
} catch (error) {
|
|
logger.error(`Failed to parse validator config file: ${error}`);
|
|
throw new Error("Invalid JSON format in validator configuration file");
|
|
}
|
|
|
|
// Validate the validators array
|
|
if (!config.validators || !Array.isArray(config.validators) || config.validators.length === 0) {
|
|
logger.error("Invalid validator configuration: 'validators' array is missing or empty");
|
|
throw new Error("Validator configuration must contain a non-empty 'validators' array");
|
|
}
|
|
|
|
// Validate each validator entry
|
|
for (const [index, validator] of config.validators.entries()) {
|
|
if (!validator.publicKey) {
|
|
throw new Error(`Validator at index ${index} is missing 'publicKey'`);
|
|
}
|
|
if (!validator.privateKey) {
|
|
throw new Error(`Validator at index ${index} is missing 'privateKey'`);
|
|
}
|
|
if (!validator.publicKey.startsWith("0x")) {
|
|
throw new Error(`Validator publicKey at index ${index} must start with '0x'`);
|
|
}
|
|
if (!validator.privateKey.startsWith("0x")) {
|
|
throw new Error(`Validator privateKey at index ${index} must start with '0x'`);
|
|
}
|
|
}
|
|
|
|
const validators = config.validators;
|
|
logger.info(`🔎 Found ${validators.length} validators to fund`);
|
|
|
|
// Get cast path for transactions
|
|
const { stdout: castPath } = await $`which cast`.quiet();
|
|
const castExecutable = castPath.toString().trim();
|
|
|
|
// Get the deployment information to find the strategies
|
|
const defaultDeploymentPath = path.resolve(`../contracts/deployments/${networkName}.json`);
|
|
const finalDeploymentPath = deploymentPath || defaultDeploymentPath;
|
|
|
|
if (!fs.existsSync(finalDeploymentPath)) {
|
|
logger.error(`Deployment file not found: ${finalDeploymentPath}`);
|
|
return false;
|
|
}
|
|
|
|
const deployments: DeploymentInfo = JSON.parse(fs.readFileSync(finalDeploymentPath, "utf8"));
|
|
|
|
// Ensure there's at least one deployed strategy
|
|
if (!deployments.DeployedStrategies || deployments.DeployedStrategies.length === 0) {
|
|
logger.error("No strategies found in deployment file - cannot proceed");
|
|
return false;
|
|
}
|
|
|
|
logger.debug(`Found ${deployments.DeployedStrategies.length} strategies with token information`);
|
|
|
|
// We need to ensure all operators to be registered have the necessary tokens
|
|
// Iterate through the strategies, using the embedded token information to fund validators
|
|
for (const strategy of deployments.DeployedStrategies) {
|
|
const strategyAddress = strategy.address;
|
|
const underlyingTokenAddress = strategy.underlyingToken;
|
|
const tokenCreator = strategy.tokenCreator;
|
|
|
|
logger.debug(
|
|
`Processing strategy ${strategyAddress} with token ${underlyingTokenAddress} created by ${tokenCreator}`
|
|
);
|
|
|
|
// Find the token creator in our validator list
|
|
const creatorValidator = validators.find((validator) => validator.publicKey === tokenCreator);
|
|
if (!creatorValidator) {
|
|
logger.error(`Token creator ${tokenCreator} not found in validators list`);
|
|
logger.warn("Will try to continue with other strategies...");
|
|
continue;
|
|
}
|
|
|
|
const creatorPrivateKey = creatorValidator.privateKey;
|
|
logger.debug(`Found token creator's private key for address ${tokenCreator}`);
|
|
|
|
// Get the ERC20 balance of the token creator and its ETH balance as well
|
|
const getErc20BalanceCmd = `${castExecutable} balance --erc20 ${underlyingTokenAddress} ${tokenCreator} --rpc-url ${rpcUrl}`;
|
|
const getEthBalanceCmd = `${castExecutable} balance ${tokenCreator} --rpc-url ${rpcUrl}`;
|
|
const { stdout: erc20BalanceOutput } = await $`sh -c ${getErc20BalanceCmd}`.quiet();
|
|
const { stdout: ethBalanceOutput } = await $`sh -c ${getEthBalanceCmd}`.quiet();
|
|
const creatorErc20Balance = erc20BalanceOutput.toString().trim().split(" ")[0];
|
|
const creatorEthBalance = ethBalanceOutput.toString().trim();
|
|
logger.debug(`Token creator has ${creatorErc20Balance} tokens and ${creatorEthBalance} ETH`);
|
|
|
|
// Transfer 5% of the creator's tokens to each validator + 1% of the creator's ETH. ETH is transferred only if the receiving validator does not have any
|
|
const erc20TransferAmount = BigInt(creatorErc20Balance) / BigInt(20); // 5% of the balance
|
|
const ethTransferAmount = BigInt(creatorEthBalance) / BigInt(100); // 1% of the balance
|
|
logger.debug(`Transferring ${erc20TransferAmount} tokens to each validator`);
|
|
|
|
for (const validator of validators) {
|
|
if (validator.publicKey !== tokenCreator) {
|
|
const transferCmd = `${castExecutable} send --private-key ${creatorPrivateKey} ${underlyingTokenAddress} "transfer(address,uint256)" ${validator.publicKey} ${erc20TransferAmount} --rpc-url ${rpcUrl}`;
|
|
const { exitCode: transferExitCode, stderr: transferStderr } = await $`sh -c ${transferCmd}`
|
|
.nothrow()
|
|
.quiet();
|
|
if (transferExitCode !== 0) {
|
|
logger.error(
|
|
`Failed to transfer tokens to validator ${validator.publicKey}: ${transferStderr.toString()}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Verify the transfer was successful
|
|
const validatorBalanceCmd = `${castExecutable} call ${underlyingTokenAddress} "balanceOf(address)(uint256)" ${validator.publicKey} --rpc-url ${rpcUrl}`;
|
|
const { stdout: validatorBalanceOutput } = await $`sh -c ${validatorBalanceCmd}`.quiet();
|
|
const validatorBalance = validatorBalanceOutput.toString().trim().split(" ")[0];
|
|
|
|
// Note: We shouldn't use strict equality here as other transactions might affect balances
|
|
if (BigInt(validatorBalance) < erc20TransferAmount) {
|
|
logger.warn(
|
|
`Validator ${validator.publicKey} has less than expected balance (${validatorBalance} < ${erc20TransferAmount})`
|
|
);
|
|
} else {
|
|
logger.success(`Successfully transferred tokens to validator ${validator.publicKey}`);
|
|
}
|
|
|
|
// Check this validator's ETH balance
|
|
const validatorEthBalanceCmd = `${castExecutable} balance ${validator.publicKey} --rpc-url ${rpcUrl}`;
|
|
const { stdout: validatorEthBalanceOutput } =
|
|
await $`sh -c ${validatorEthBalanceCmd}`.quiet();
|
|
const validatorEthBalance = validatorEthBalanceOutput.toString().trim();
|
|
logger.debug(`Validator ${validator.publicKey} has ${validatorEthBalance} ETH`);
|
|
|
|
// Transfer ETH only if the validator has no ETH
|
|
if (BigInt(validatorEthBalance) === BigInt(0)) {
|
|
const ethTransferCmd = `${castExecutable} send --private-key ${creatorPrivateKey} ${validator.publicKey} --value ${ethTransferAmount} --rpc-url ${rpcUrl}`;
|
|
const { exitCode: ethTransferExitCode, stderr: ethTransferStderr } =
|
|
await $`sh -c ${ethTransferCmd}`.nothrow().quiet();
|
|
if (ethTransferExitCode !== 0) {
|
|
logger.error(
|
|
`Failed to transfer ETH to validator ${validator.publicKey}: ${ethTransferStderr.toString()}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Verify the ETH transfer was successful
|
|
const validatorEthBalanceAfterCmd = `${castExecutable} balance ${validator.publicKey} --rpc-url ${rpcUrl}`;
|
|
const { stdout: validatorEthBalanceAfterOutput } =
|
|
await $`sh -c ${validatorEthBalanceAfterCmd}`.quiet();
|
|
const validatorEthBalanceAfter = validatorEthBalanceAfterOutput.toString().trim();
|
|
if (BigInt(validatorEthBalanceAfter) < ethTransferAmount) {
|
|
logger.warn(
|
|
`Validator ${validator.publicKey} has less than expected ETH balance (${validatorEthBalanceAfter} < ${ethTransferAmount})`
|
|
);
|
|
} else {
|
|
logger.success(`Successfully transferred ETH to validator ${validator.publicKey}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.success("All validators have been funded with tokens");
|
|
|
|
return true;
|
|
};
|
|
|
|
// Allow script to be run directly with CLI arguments
|
|
if (import.meta.main) {
|
|
const args = process.argv.slice(2);
|
|
const options: {
|
|
rpcUrl?: string;
|
|
validatorsConfig?: string;
|
|
networkName?: string;
|
|
deploymentPath?: string;
|
|
} = {
|
|
networkName: "anvil" // Default network name
|
|
};
|
|
|
|
// Extract RPC URL
|
|
const rpcUrlIndex = args.indexOf("--rpc-url");
|
|
if (rpcUrlIndex !== -1 && rpcUrlIndex + 1 < args.length) {
|
|
options.rpcUrl = args[rpcUrlIndex + 1];
|
|
}
|
|
|
|
// Extract validators config path
|
|
const configIndex = args.indexOf("--config");
|
|
if (configIndex !== -1 && configIndex + 1 < args.length) {
|
|
options.validatorsConfig = args[configIndex + 1];
|
|
}
|
|
|
|
// Extract network name
|
|
const networkIndex = args.indexOf("--network");
|
|
if (networkIndex !== -1 && networkIndex + 1 < args.length) {
|
|
options.networkName = args[networkIndex + 1];
|
|
}
|
|
|
|
// Extract custom deployment path
|
|
const deploymentPathIndex = args.indexOf("--deployment-path");
|
|
if (deploymentPathIndex !== -1 && deploymentPathIndex + 1 < args.length) {
|
|
options.deploymentPath = args[deploymentPathIndex + 1];
|
|
}
|
|
|
|
// Check required parameters
|
|
if (!options.rpcUrl) {
|
|
console.error("Error: --rpc-url parameter is required");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Run funding
|
|
fundValidators({
|
|
rpcUrl: options.rpcUrl,
|
|
validatorsConfig: options.validatorsConfig,
|
|
networkName: options.networkName,
|
|
deploymentPath: options.deploymentPath
|
|
}).catch((error) => {
|
|
console.error("Validator funding failed:", error);
|
|
process.exit(1);
|
|
});
|
|
}
|