feat: injecting contracts feature for e2e testing (#295)

In this PR, we introduce a way to save Ethereum state into a file. This
saved state can then be injected into Ethereum to speed up e2e initial
test setup.

This is a rewrite of the now closed PR
https://github.com/datahaven-xyz/datahaven/pull/90 .

It uses a an external tool written in rust to save state from the
Ethereum running container : https://github.com/undercover-cactus/Chaos

---------

Co-authored-by: Gonza Montiel <gonzamontiel@users.noreply.github.com>
Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com>
This commit is contained in:
undercover-cactus 2025-11-20 12:42:12 +01:00 committed by GitHub
parent 32480f1bbc
commit 077cc9ed29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 274 additions and 172 deletions

View file

@ -26,7 +26,7 @@
},
"json": {
"formatter": {
"enabled": false
"enabled": true
}
},
"javascript": {

View file

@ -1,3 +0,0 @@
# Ignoring local deployments
anvil.json
anvil-rewards-info.json

View file

@ -0,0 +1,5 @@
{
"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7",
"RewardsAgentOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000",
"updateRewardsMerkleRootSelector": "0xdc3d04ec"
}

View file

@ -0,0 +1,28 @@
{
"network": "anvil",
"BeefyClient": "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf",
"AgentExecutor": "0x0E801D84Fa97b50751Dbf25036d067dCf18858bF",
"Gateway": "0x9d4454B023096f34B160D6B654540c56A1F81688",
"ServiceManager": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D",
"ServiceManagerImplementation": "0x36C02dA8a0983159322a80FFE9F24b1acfF8B570",
"VetoableSlasher": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029",
"RewardsRegistry": "0x1291Be112d480055DaFd8a610b7d1e203891C274",
"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7",
"DelegationManager": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82",
"StrategyManager": "0x9A676e781A523b5d0C0e43731313A708CB607508",
"AVSDirectory": "0x0B306BF915C4d645ff596e518fAf3F9669b97016",
"EigenPodManager": "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1",
"EigenPodBeacon": "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
"RewardsCoordinator": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
"AllocationManager": "0x68B1D87F95878fE05B998F19b66F4baba5De1aed",
"PermissionController": "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c",
"ETHPOSDeposit": "0xC7f2Cf4845C6db0e1a1e91ED41Bcd0FcC1b0E141",
"BaseStrategyImplementation": "0xf5059a5D33d5853360D16C683c16e67980206f36",
"DeployedStrategies": [
{
"address": "0x998abeb3E57409262aE5b751f60747921B33613E",
"underlyingToken": "0x95401dc811bb5740090279Ba06cfA8fcF6113778",
"tokenCreator": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
}
]
}

File diff suppressed because one or more lines are too long

8
test/Makefile Normal file
View file

@ -0,0 +1,8 @@
version := v0.1.2
generate-ethereum-state:
docker exec $(shell docker ps -aq --filter name='el-1-reth') bash -c "apt update && apt install -y wget"
docker exec $(shell docker ps -aq --filter name='el-1-reth') bash -c "wget https://github.com/undercover-cactus/Chaos/releases/download/${version}/chaos-linux-amd64-${version}.tar.gz"
docker exec $(shell docker ps -aq --filter name='el-1-reth') bash -c "tar -xzvf chaos-linux-amd64-${version}.tar.gz"
docker exec $(shell docker ps -aq --filter name='el-1-reth') bash -c "./target/release/chaos --database-path /data/reth/execution-data/db"
docker cp $(shell docker ps -aq --filter name='el-1-reth'):state.json ../contracts/deployments/state-diff.json

View file

@ -55,6 +55,19 @@ bun test:e2e:parallel
bun test suites/some-test.test.ts
```
## Generating Ethereum state
To avoid deploying contracts everytime for each tests, you can generate and then inject state in the Ethereum client.
### Generate state
```
$ bun cli launch --all
$ make generate-ethereum-state
$ bun cli stop --all
```
## What Gets Launched
The `bun cli launch` command deploys a complete local environment:

View file

@ -1,18 +1,18 @@
[
{
"name": "EthereumGatewayAddress",
"value": null
},
{
"name": "RewardsRegistryAddress",
"value": null
},
{
"name": "RewardsUpdateSelector",
"value": null
},
{
"name": "RewardsAgentOrigin",
"value": null
}
]
{
"name": "EthereumGatewayAddress",
"value": null
},
{
"name": "RewardsRegistryAddress",
"value": null
},
{
"name": "RewardsUpdateSelector",
"value": null
},
{
"name": "RewardsAgentOrigin",
"value": null
}
]

View file

@ -19,17 +19,17 @@
"PragueTime": null,
"terminalTotalDifficultyPassed": true,
"blobSchedule": {
"cancun": {
"target": 3,
"max": 6,
"baseFeeUpdateFraction": 3338477
},
"prague": {
"target": 6,
"max": 9,
"baseFeeUpdateFraction": 5007716
"cancun": {
"target": 3,
"max": 6,
"baseFeeUpdateFraction": 3338477
},
"prague": {
"target": 6,
"max": 9,
"baseFeeUpdateFraction": 5007716
}
}
}
},
"difficulty": "0x9FFE0",
"gasLimit": "80000000",

View file

@ -541,4 +541,4 @@
"0x62b01d11535ad0170a07b36750c67bd3b864ed80f1bb11a3e99b8fb60786a266",
"0xa1381fdc64967103fe79c0705727851ce61e7f91bee7e3e7759f9283c91ff7ff"
]
}
}

View file

@ -3087,4 +3087,4 @@
]
}
}
}
}

View file

@ -13766,4 +13766,4 @@
]
}
}
}
}

View file

@ -1,40 +1,40 @@
{
"validators": [
{
"publicKey": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"privateKey": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"solochainAddress": "0xE04CC55ebEE1cBCE552f250e85c57B70B2E2625b",
"solochainPrivateKey": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
"solochainAuthorityName": "alice"
},
{
"publicKey": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"privateKey": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
"solochainAddress": "0x25451A4de12dcCc2D166922fA938E900fCc4ED24",
"solochainPrivateKey": "0x8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b",
"solochainAuthorityName": "bob"
},
{
"publicKey": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"privateKey": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
"solochainAddress": "0x9e250513a9f2f287d0cdd636dac97b2405098bd5",
"solochainPrivateKey": "0x0b6e18cafb6ed99687ec547bd28139cafdd2bffe70e6b688025de6b445aa5c5b",
"solochainAuthorityName": "charlie"
},
{
"publicKey": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"privateKey": "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6",
"solochainAddress": "0x6babb2c13fa50cbae5c7d256ff4e3064e3110dab",
"solochainPrivateKey": "0x39539ab1876910bbf3a223d84a29e28f1cb4e2e456503e7e91ed39b2e7223d68",
"solochainAuthorityName": "dave"
},
{
"publicKey": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
"privateKey": "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a",
"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"
}
"validators": [
{
"publicKey": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"privateKey": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"solochainAddress": "0xE04CC55ebEE1cBCE552f250e85c57B70B2E2625b",
"solochainPrivateKey": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
"solochainAuthorityName": "alice"
},
{
"publicKey": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"privateKey": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
"solochainAddress": "0x25451A4de12dcCc2D166922fA938E900fCc4ED24",
"solochainPrivateKey": "0x8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b",
"solochainAuthorityName": "bob"
},
{
"publicKey": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"privateKey": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
"solochainAddress": "0x9e250513a9f2f287d0cdd636dac97b2405098bd5",
"solochainPrivateKey": "0x0b6e18cafb6ed99687ec547bd28139cafdd2bffe70e6b688025de6b445aa5c5b",
"solochainAuthorityName": "charlie"
},
{
"publicKey": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"privateKey": "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6",
"solochainAddress": "0x6babb2c13fa50cbae5c7d256ff4e3064e3110dab",
"solochainPrivateKey": "0x39539ab1876910bbf3a223d84a29e28f1cb4e2e456503e7e91ed39b2e7223d68",
"solochainAuthorityName": "dave"
},
{
"publicKey": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
"privateKey": "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a",
"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"
}

View file

@ -91,8 +91,9 @@ export const launchLocalDataHavenSolochain = async (
if (options.buildDatahaven) {
await buildLocalImage(options);
} else {
await checkTagExists(options.datahavenImageTag);
}
await checkTagExists(options.datahavenImageTag);
// Create a unique Docker network name using the network ID
const dockerNetworkName = `datahaven-${options.networkId}`;

View file

@ -8,6 +8,7 @@ import {
runShellCommandWithLogger
} from "utils";
import { parse, stringify } from "yaml";
import { z } from "zod";
import type { LaunchedNetwork } from "./types/launchedNetwork";
/**
@ -18,6 +19,7 @@ export interface KurtosisOptions {
blockscout?: boolean;
slotTime?: number;
kurtosisNetworkArgs?: string;
injectContracts?: boolean;
}
/**
@ -192,6 +194,7 @@ export const modifyConfig = async (
slotTime?: number;
kurtosisNetworkArgs?: string;
kurtosisEnclaveName?: string;
injectContracts?: boolean;
},
configFile: string
): Promise<string> => {
@ -225,6 +228,39 @@ export const modifyConfig = async (
}
}
// Load and validate pre-deployed contracts
if (options.injectContracts) {
try {
const preDeployedFile = Bun.file("../contracts/deployments/state-diff.json");
if (await preDeployedFile.exists()) {
logger.debug(`Pre-deployed contracts file: ${preDeployedFile.name}`);
const preDeployedRaw = await preDeployedFile.text();
logger.trace(`Raw pre-deployed contracts data: ${preDeployedRaw}`);
const preDeployedData = JSON.parse(preDeployedRaw);
const validatedContracts = preDeployedContractsSchema.parse(preDeployedData);
logger.trace(`Validated contracts: ${JSON.stringify(validatedContracts, null, 2)}`);
const kurtosisFormattedContracts = transformToKurtosisFormat(validatedContracts);
logger.trace(
`Kurtosis formatted contracts: ${JSON.stringify(kurtosisFormattedContracts, null, 2)}`
);
parsedConfig.network_params.additional_preloaded_contracts = JSON.stringify(
kurtosisFormattedContracts,
null,
0
);
logger.debug("Pre-deployed contracts loaded and validated successfully");
} else {
logger.warn("Pre-deployed contracts file not found, skipping");
}
} catch (error) {
logger.error(`Failed to load pre-deployed contracts: ${error}`);
throw new Error("❌ Invalid pre-deployed contracts configuration");
}
}
logger.trace(parsedConfig);
// Use a unique filename based on the enclave name to avoid conflicts in parallel execution
const configFileName = options.kurtosisEnclaveName
@ -344,3 +380,34 @@ export const getBlockscoutUrl = async (enclaveName: string): Promise<string> =>
invariant(blockscoutPort, "❌ Could not find Blockscout service port");
return `http://127.0.0.1:${blockscoutPort}`;
};
const preDeployedContractsSchema = z.record(
z.string(),
z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"),
code: z.string().regex(/^0x[a-fA-F0-9]*$/, "Invalid hex code"),
storage: z.union([
z.record(z.string(), z.string()),
z.string() // Allow empty string for contracts with no storage
])
})
);
const transformToKurtosisFormat = (contracts: z.infer<typeof preDeployedContractsSchema>) => {
const transformed: Record<string, any> = {};
for (const [_name, contract] of Object.entries(contracts)) {
// Handle storage - convert empty string to empty object
const storage =
typeof contract.storage === "string" && contract.storage === "" ? {} : contract.storage;
transformed[contract.address] = {
balance: "0ETH",
code: contract.code,
storage: storage,
nonce: "0x0" // Default nonce to 0
};
}
return transformed;
};

View file

@ -1,7 +1,7 @@
import { $ } from "bun";
import { getContainersMatchingImage, getPortFromKurtosis, logger } from "utils";
import { getContainersMatchingImage, logger } from "utils";
import { ParameterCollection } from "utils/parameters";
import { deployContracts } from "../contracts";
import { updateParameters } from "../../scripts/deploy-contracts";
import { launchLocalDataHavenSolochain } from "../datahaven";
import { getRunningKurtosisEnclaves, launchKurtosisNetwork } from "../kurtosis";
import { setDataHavenParameters } from "../parameters";
@ -186,30 +186,19 @@ export const launchNetwork = async (
kurtosisEnclaveName: kurtosisEnclaveName,
blockscout: options.blockscout ?? false,
slotTime: options.slotTime || 2,
kurtosisNetworkArgs: options.kurtosisNetworkArgs
kurtosisNetworkArgs: options.kurtosisNetworkArgs,
injectContracts: true // Forcing it to be true to run e2e tests
},
launchedNetwork
);
// 3. Deploy contracts
logger.info("📄 Deploying smart contracts...");
let blockscoutBackendUrl: string | undefined;
if (options.blockscout) {
const blockscoutPort = await getPortFromKurtosis("blockscout", "http", kurtosisEnclaveName);
blockscoutBackendUrl = `http://127.0.0.1:${blockscoutPort}`;
}
logger.info("📄 Smart contracts injected.");
if (!launchedNetwork.elRpcUrl) {
throw new Error("Ethereum RPC URL not available");
}
await deployContracts({
rpcUrl: launchedNetwork.elRpcUrl,
verified: options.verified ?? false,
blockscoutBackendUrl,
parameterCollection
});
// 4. Fund validators
logger.info("💰 Funding validators...");
await fundValidators({
@ -222,6 +211,9 @@ export const launchNetwork = async (
rpcUrl: launchedNetwork.elRpcUrl
});
// We are injecting contracts but we still need the addresses
await updateParameters(parameterCollection);
// 6. Set DataHaven runtime parameters
logger.info("⚙️ Setting DataHaven parameters...");
await setDataHavenParameters({

View file

@ -6,26 +6,14 @@
"environments": [
{
"name": "dev_datahaven",
"testFileDir": [
"datahaven/suites/dev"
],
"include": [
"**/*test*.ts"
],
"testFileDir": ["datahaven/suites/dev"],
"include": ["**/*test*.ts"],
"timeout": 180000,
"multiThreads": 4,
"contracts": "datahaven/contracts/",
"runScripts": [
"compile-contracts.sh compile"
],
"envVars": [
"DEBUG_COLORS=1"
],
"reporters": [
"basic",
"html",
"json"
],
"runScripts": ["compile-contracts.sh compile"],
"envVars": ["DEBUG_COLORS=1"],
"reporters": ["basic", "html", "json"],
"reportFile": {
"json": "./tmp/testResults.json"
},
@ -50,4 +38,4 @@
}
}
]
}
}

View file

@ -82,4 +82,4 @@
"ssh2",
"utf-8-validate"
]
}
}

View file

@ -97,64 +97,72 @@ export const executeDeployment = async (
// - RewardsAgentOrigin (bytes32)
// and add it to parameters if collection is provided
if (parameterCollection) {
try {
const deployments = await parseDeploymentsFile(chain);
const rewardsInfo = await parseRewardsInfoFile(chain);
const gatewayAddress = deployments.Gateway;
const rewardsRegistryAddress = deployments.RewardsRegistry;
const rewardsAgentOrigin = rewardsInfo.RewardsAgentOrigin;
const updateRewardsMerkleRootSelector = rewardsInfo.updateRewardsMerkleRootSelector;
if (gatewayAddress) {
logger.debug(`📝 Adding EthereumGatewayAddress parameter: ${gatewayAddress}`);
parameterCollection.addParameter({
name: "EthereumGatewayAddress",
value: gatewayAddress
});
} else {
logger.warn("⚠️ Gateway address not found in deployments file");
}
if (rewardsRegistryAddress) {
logger.debug(`📝 Adding RewardsRegistryAddress parameter: ${rewardsRegistryAddress}`);
parameterCollection.addParameter({
name: "RewardsRegistryAddress",
value: rewardsRegistryAddress
});
} else {
logger.warn("⚠️ RewardsRegistry address not found in deployments file");
}
if (updateRewardsMerkleRootSelector) {
logger.debug(
`📝 Adding RewardsUpdateSelector parameter: ${updateRewardsMerkleRootSelector}`
);
parameterCollection.addParameter({
name: "RewardsUpdateSelector",
value: updateRewardsMerkleRootSelector
});
} else {
logger.warn("⚠️ updateRewardsMerkleRootSelector not found in rewards info file");
}
if (rewardsAgentOrigin) {
logger.debug(`📝 Adding RewardsAgentOrigin parameter: ${rewardsAgentOrigin}`);
parameterCollection.addParameter({
name: "RewardsAgentOrigin",
value: rewardsAgentOrigin
});
} else {
logger.warn("⚠️ RewardsAgentOrigin not found in deployments file");
}
} catch (error) {
logger.error(`Failed to read parameters from deployment: ${error}`);
}
await updateParameters(parameterCollection, chain);
}
logger.success("Contracts deployed successfully");
};
/**
* Read the parameters from the deployed contracts and add it to the collection.
*/
export const updateParameters = async (
parameterCollection: ParameterCollection,
chain?: string
) => {
try {
const deployments = await parseDeploymentsFile(chain);
const rewardsInfo = await parseRewardsInfoFile(chain);
const gatewayAddress = deployments.Gateway;
const rewardsRegistryAddress = deployments.RewardsRegistry;
const rewardsAgentOrigin = rewardsInfo.RewardsAgentOrigin;
const updateRewardsMerkleRootSelector = rewardsInfo.updateRewardsMerkleRootSelector;
if (gatewayAddress) {
logger.debug(`📝 Adding EthereumGatewayAddress parameter: ${gatewayAddress}`);
parameterCollection.addParameter({
name: "EthereumGatewayAddress",
value: gatewayAddress
});
} else {
logger.warn("⚠️ Gateway address not found in deployments file");
}
if (rewardsRegistryAddress) {
logger.debug(`📝 Adding RewardsRegistryAddress parameter: ${rewardsRegistryAddress}`);
parameterCollection.addParameter({
name: "RewardsRegistryAddress",
value: rewardsRegistryAddress
});
} else {
logger.warn("⚠️ RewardsRegistry address not found in deployments file");
}
if (updateRewardsMerkleRootSelector) {
logger.debug(`📝 Adding RewardsUpdateSelector parameter: ${updateRewardsMerkleRootSelector}`);
parameterCollection.addParameter({
name: "RewardsUpdateSelector",
value: updateRewardsMerkleRootSelector
});
} else {
logger.warn("⚠️ updateRewardsMerkleRootSelector not found in rewards info file");
}
if (rewardsAgentOrigin) {
logger.debug(`📝 Adding RewardsAgentOrigin parameter: ${rewardsAgentOrigin}`);
parameterCollection.addParameter({
name: "RewardsAgentOrigin",
value: rewardsAgentOrigin
});
} else {
logger.warn("⚠️ RewardsAgentOrigin not found in deployments file");
}
} catch (error) {
logger.error(`Failed to read parameters from deployment: ${error}`);
}
};
/**
* Main function to deploy contracts with simplified interface
* This is the main entry point for CLI handlers

View file

@ -1,9 +1,7 @@
{
"compilerOptions": {
// Enable latest features
"lib": [
"ESNext"
],
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
@ -33,12 +31,8 @@
"tsBuildInfoFile": "tmp/tsconfig.tsbuildinfo",
"assumeChangesOnlyAffectDirectDependencies": true,
"paths": {
"utils": [
"./utils/index.ts"
],
"utils/types": [
"./utils/types.ts"
]
"utils": ["./utils/index.ts"],
"utils/types": ["./utils/types.ts"]
}
},
"include": [
@ -51,4 +45,4 @@
"launcher/**/*.ts",
"framework/**/*.ts"
]
}
}