datahaven/test/e2e/suites/validator-set-update.test.ts
Ahmad Kaouk 39aea69e36
test: integrate validator-set-submitter Docker container into E2E test (#453)
## Summary

- Replace the manual `sendNewValidatorSetForEra` contract call in the
`validator-set-update` E2E test with the **validator-set-submitter
daemon** running as a Docker container
- Add `test/e2e/framework/submitter.ts` with helpers to build the image,
launch the container on the shared Docker network, and clean up after
the test
- Remove unused imports (`getOwnerAccount`, `decodeEventLog`,
`parseEther`, `gatewayAbi`) that were only needed for manual submission

The submitter automatically detects the last session of an era and
submits the validator set via Snowbridge, matching production behavior
more closely than the previous manual call.

## Test plan

- [x] `bun test e2e/suites/validator-set-update.test.ts --timeout
900000` passes (4/4 tests, 15 assertions)
- [x] Verify submitter container starts and connects to both Ethereum
and DataHaven
- [x] Verify `ExternalValidatorsSet` event is observed on DataHaven
- [x] Verify submitter container is cleaned up after the test
- [x] Verify Charlie and Dave appear in the final validator set
2026-02-24 18:31:49 +02:00

248 lines
9 KiB
TypeScript

/**
* 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.sendNewValidatorSetForEra`,
* assert Gateway `OutboundMessageAccepted`.
* - Observe `ExternalValidators.ExternalValidatorsSet` on DataHaven (substrate), confirming propagation.
*/
import { beforeAll, describe, expect, it } from "bun:test";
import {
CROSS_CHAIN_TIMEOUTS,
type Deployments,
getPapiSigner,
logger,
parseDeploymentsFile,
ZERO_ADDRESS
} from "utils";
import { waitForDataHavenEvent } from "utils/events";
import { dataHavenServiceManagerAbi } from "../../contract-bindings";
import {
addValidatorToAllowlist,
BaseTestSuite,
buildSubmitterImage,
getValidator,
isValidatorRunning,
launchDatahavenValidator,
launchSubmitter,
registerOperator,
type TestConnectors
} from "../framework";
class ValidatorSetUpdateTestSuite extends BaseTestSuite {
constructor() {
super({
suiteName: "validator-set-update"
});
this.setupHooks();
}
override async onSetup(): Promise<void> {
// Launch two new nodes to be authorities
logger.debug("Launching Charlie and Dave validators...");
const { launchedNetwork } = this.getConnectors();
await Promise.all([
launchDatahavenValidator("charlie", { launchedNetwork }),
launchDatahavenValidator("dave", { launchedNetwork })
]);
// Build the submitter Docker image so it's ready for the test
await buildSubmitterImage();
}
public getNetworkId(): string {
return this.getConnectors().launchedNetwork.networkId;
}
public getLaunchedNetwork() {
return this.getConnectors().launchedNetwork;
}
}
// Create the test suite instance
const suite = new ValidatorSetUpdateTestSuite();
let deployments: Deployments;
let connectors: TestConnectors;
describe("Validator Set Update", () => {
const initialValidators = [getValidator("alice"), getValidator("bob")];
const newValidators = [getValidator("charlie"), getValidator("dave")];
beforeAll(async () => {
deployments = await parseDeploymentsFile();
connectors = suite.getTestConnectors();
// Pause era rotation early so the active era stabilizes during tests 1-3 (~28s),
// avoiding the ~80s wait inside the cross-chain test.
// Tests 1-3 only touch Ethereum contracts and don't depend on era rotation.
const { dhApi } = connectors;
const pauseTx = dhApi.tx.Sudo.sudo({
call: dhApi.tx.ExternalValidators.force_era({
mode: { type: "ForceNone", value: undefined }
}).decodedCall
});
const pauseResult = await pauseTx.signAndSubmit(getPapiSigner("ALITH"));
if (!pauseResult.ok) {
throw new Error("Failed to pause era rotation");
}
});
it("should verify test environment", async () => {
const networkId = suite.getNetworkId();
const { publicClient, papiClient } = connectors;
// Validators running
expect(await isValidatorRunning("alice", networkId)).toBe(true);
expect(await isValidatorRunning("bob", networkId)).toBe(true);
expect(await isValidatorRunning("charlie", networkId)).toBe(true);
expect(await isValidatorRunning("dave", networkId)).toBe(true);
// Chain connectivity
expect(await publicClient.getBlockNumber()).toBeGreaterThan(0);
expect((await papiClient.getBlockHeader()).number).toBeGreaterThan(0);
// Contract deployed
expect(deployments.ServiceManager).toBeDefined();
});
it("should verify initial validator set state", async () => {
const { publicClient } = connectors;
const readSolochainAddress = (validator: (typeof initialValidators)[0]) =>
publicClient.readContract({
address: deployments.ServiceManager as `0x${string}`,
abi: dataHavenServiceManagerAbi,
functionName: "validatorEthAddressToSolochainAddress",
args: [validator.publicKey as `0x${string}`]
});
// Check initial validators have correct mappings and new validators are not registered
const [initialResults, newResults] = await Promise.all([
Promise.all(initialValidators.map(readSolochainAddress)),
Promise.all(newValidators.map(readSolochainAddress))
]);
expect(initialResults).toEqual(
initialValidators.map((v) => v.solochainAddress as `0x${string}`)
);
expect(newResults).toEqual(newValidators.map(() => ZERO_ADDRESS));
});
it("should allowlist and register new validators as operators", async () => {
const opts = { connectors, deployments };
// Add to allowlist sequentially
await addValidatorToAllowlist("charlie", opts);
await addValidatorToAllowlist("dave", opts);
// Register operators in parallel (each uses their own validator account)
await Promise.all([registerOperator("charlie", opts), registerOperator("dave", opts)]);
// Verify allowlist and registration status
const { publicClient } = connectors;
const isAllowlisted = (name: string) =>
publicClient.readContract({
address: deployments.ServiceManager as `0x${string}`,
abi: dataHavenServiceManagerAbi,
functionName: "validatorsAllowlist",
args: [getValidator(name).publicKey as `0x${string}`]
});
const isRegistered = async (name: string) => {
const validator = getValidator(name);
const solochainAddress = await publicClient.readContract({
address: deployments.ServiceManager as `0x${string}`,
abi: dataHavenServiceManagerAbi,
functionName: "validatorEthAddressToSolochainAddress",
args: [validator.publicKey as `0x${string}`]
});
return solochainAddress.toLowerCase() === validator.solochainAddress.toLowerCase();
};
const [charlieAllowlisted, daveAllowlisted, charlieRegistered, daveRegistered] =
await Promise.all([
isAllowlisted("charlie"),
isAllowlisted("dave"),
isRegistered("charlie"),
isRegistered("dave")
]);
expect(charlieAllowlisted).toBe(true);
expect(daveAllowlisted).toBe(true);
expect(charlieRegistered).toBe(true);
expect(daveRegistered).toBe(true);
});
it(
"should send updated validator set and verify on DataHaven",
async () => {
const { dhApi } = connectors;
// Era rotation was paused in beforeAll. Wait for any pending transition to settle
// (ForceNone prevents new eras, but an in-progress one must finish first).
let stableEraIndex: number;
// eslint-disable-next-line no-constant-condition
while (true) {
const activeEra = (await dhApi.query.ExternalValidators.ActiveEra.getValue())?.index ?? 0;
const currentEra = (await dhApi.query.ExternalValidators.CurrentEra.getValue()) ?? 0;
if (currentEra === activeEra) {
stableEraIndex = activeEra;
break;
}
await new Promise((r) => setTimeout(r, 6_000)); // ~1 substrate block
}
const targetEra = BigInt(stableEraIndex + 1);
const validatorSetUpdated = waitForDataHavenEvent({
api: dhApi,
pallet: "ExternalValidators",
event: "ExternalValidatorsSet",
filter: (event: { external_index: number | bigint }) =>
BigInt(event.external_index) === targetEra,
timeout: CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
});
// Prevent unhandled rejection if launchSubmitter fails before we await this promise.
void validatorSetUpdated.catch(() => undefined);
// Launch the submitter daemon — it will detect the last-session condition
// and automatically call sendNewValidatorSetForEra on the ServiceManager.
const launchedNetwork = suite.getLaunchedNetwork();
const { cleanup: cleanupSubmitter } = await launchSubmitter({
networkName: launchedNetwork.networkName,
networkId: suite.getNetworkId(),
ethereumRpcUrl: connectors.elRpcUrl,
datahavenContainerName: `datahaven-alice-${suite.getNetworkId()}`,
serviceManagerAddress: deployments.ServiceManager
});
try {
logger.info("Waiting for ExternalValidators.ExternalValidatorsSet event on DataHaven...");
// Wait for the validator set to be updated on Substrate
await validatorSetUpdated;
} finally {
await cleanupSubmitter();
}
// Resume era rotation
const resumeTx = dhApi.tx.Sudo.sudo({
call: dhApi.tx.ExternalValidators.force_era({
mode: { type: "NotForcing", value: undefined }
}).decodedCall
});
await resumeTx.signAndSubmit(getPapiSigner("ALITH"));
// Verify new validators are in storage
const validators = await dhApi.query.ExternalValidators.ExternalValidators.getValue();
const expectedAddresses = newValidators.map((v) => v.solochainAddress.toLowerCase());
for (const address of expectedAddresses) {
expect(validators.some((v) => v.toLowerCase() === address)).toBe(true);
}
},
CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
);
});