test: refactor e2e tests (#365)

This PR significantly refactors and improves the end-to-end testing
framework and infrastructure. The primary focus was on simplifying the
test suites, improving reliability through better resource management,
and hardening the relayer infrastructure.

All E2E tests are now passing on the CI and demonstrate consistent
reliability when run locally.

### Key Changes

#### 1. E2E Test Suite Refactor & Cleanup
* **Simplified Test Logic**: Heavily refactored the core test suites
(`native-token-transfer.test.ts`, `rewards-message.test.ts`, and
`validator-set-update.test.ts`). The new implementation is much cleaner,
utilizing shared helpers to reduce boilerplate.
* **Utility Consolidation**: Removed redundant utility files
(`storage.ts`, `rewards-helpers.ts`) and simplified `events.ts`. Event
waiting now uses `rxjs` for Substrate and native `viem` watchers for
Ethereum, which is more robust and easier to maintain.
* **Better Connector Management**: Unified the creation and cleanup of
test clients in `ConnectorFactory`. It now handles the lifecycle of
WebSocket connections more gracefully, including clearing the
`socketClientCache` to prevent reconnection noise during teardown.

#### 2. Infrastructure & Stability
* **Relayer Relaunch Policy**: Added a restart policy for Snowbridge
relayer containers. They are now configured with `--restart
on-failure:5`, ensuring that relayers automatically relaunch if they
crash during the sensitive initialization phase.
*   **WebSocket Integration**: 
* Updated the `ConnectorFactory` to prefer **WebSockets** for the
Ethereum public client, which is essential for efficient, event-heavy
E2E testing.
* Enhanced `launchKurtosisNetwork` to correctly identify and register
the Execution Layer's WebSocket endpoint from Kurtosis.
* **Disabled Contract Injection**: This PR temporarily disables the
automatic injection of contracts into the genesis state by default.
* *Reason*: I encountered issues generating a valid `state-diff.json`
for the latest contract versions. Even after applying several
workarounds, the injected state remained unstable. As a result, I've
reverted to manual contract deployment during the launch sequence for
better reliability for now.

#### 3. Documentation & Maintenance
* Removed obsolete documentation (`event-utilities-guide.md`) that no
longer reflects the simplified event-handling API.
* Cleaned up `test/launcher/validators.ts` and moved logic into more
appropriate helpers.

---------

Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com>
This commit is contained in:
Ahmad Kaouk 2025-12-24 13:31:40 +01:00 committed by GitHub
parent 728dabb178
commit 41788d56bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1047 additions and 2622 deletions

View file

@ -33,7 +33,7 @@ env:
KURTOSIS_CORE_IMAGE: ghcr.io/stiiifff/kurtosis/kurtosis-core
KURTOSIS_ENGINE_IMAGE: ghcr.io/stiiifff/kurtosis/kurtosis-engine
KURTOSIS_VERSION: 1.11.1
INJECT_CONTRACTS: true
INJECT_CONTRACTS: false
jobs:
kurtosis:

View file

@ -33,7 +33,7 @@ interface IRewardsRegistryEvents {
* @param newRoot The new merkle root
* @param newRootIndex The index of the new root in the history
*/
event RewardsMerkleRootUpdated(bytes32 oldRoot, bytes32 newRoot, uint256 newRootIndex);
event RewardsMerkleRootUpdated(bytes32 oldRoot, bytes32 indexed newRoot, uint256 newRootIndex);
/**
* @notice Emitted when rewards are claimed for a specific root index

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.27;
/**
* @title SortedMerkleProof
* @notice Verifies sorted-hash Merkle proofs (pair order determined by hash value).
* This matches the runtime merkle tree used in DataHaven rewards.
*/
library SortedMerkleProof {
/**
* @notice Verify that a leaf is part of a sorted-hash Merkle tree.
* @param root the root of the merkle tree
* @param leaf the leaf hash
* @param position the position of the leaf (only used for bounds check)
* @param width the number of leaves in the tree
* @param proof the array of proofs from leaf to root
*/
function verify(
bytes32 root,
bytes32 leaf,
uint256 position,
uint256 width,
bytes32[] calldata proof
) internal pure returns (bool) {
if (position >= width) {
return false;
}
bytes32 node = leaf;
for (uint256 i = 0; i < proof.length; i++) {
bytes32 sibling = proof[i];
node = node < sibling ? efficientHash(node, sibling) : efficientHash(sibling, node);
}
return node == root;
}
/**
* @notice Efficiently hashes two bytes32 values using assembly
*/
function efficientHash(
bytes32 a,
bytes32 b
) internal pure returns (bytes32 value) {
assembly {
mstore(0x00, a)
mstore(0x20, b)
value := keccak256(0x00, 0x40)
}
}
}

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.27;
import {SubstrateMerkleProof} from "snowbridge/src/utils/SubstrateMerkleProof.sol";
import {SortedMerkleProof} from "../libraries/SortedMerkleProof.sol";
import {ScaleCodec} from "snowbridge/src/utils/ScaleCodec.sol";
import {IDataHavenServiceManager} from "../interfaces/IDataHavenServiceManager.sol";
import {RewardsRegistryStorage} from "./RewardsRegistryStorage.sol";
@ -74,7 +74,7 @@ contract RewardsRegistry is RewardsRegistryStorage {
}
/**
* @notice Claim rewards for an operator from a specific merkle root index using Substrate/Snowbridge positional Merkle proofs.
* @notice Claim rewards for an operator from a specific merkle root index using sorted-hash Merkle proofs.
* @param operatorAddress Address of the operator to receive rewards
* @param rootIndex Index of the merkle root to claim from
* @param operatorPoints Points earned by the operator
@ -100,7 +100,7 @@ contract RewardsRegistry is RewardsRegistryStorage {
}
/**
* @notice Claim rewards for an operator from the latest merkle root using Substrate/Snowbridge positional Merkle proofs.
* @notice Claim rewards for an operator from the latest merkle root using sorted-hash Merkle proofs.
* @param operatorAddress Address of the operator to receive rewards
* @param operatorPoints Points earned by the operator
* @param numberOfLeaves The total number of leaves in the Merkle tree
@ -128,13 +128,13 @@ contract RewardsRegistry is RewardsRegistryStorage {
}
/**
* @notice Claim rewards for an operator from multiple merkle root indices using Substrate/Snowbridge positional Merkle proofs.
* @notice Claim rewards for an operator from multiple merkle root indices using sorted-hash Merkle proofs.
* @param operatorAddress Address of the operator to receive rewards
* @param rootIndices Array of merkle root indices to claim from
* @param operatorPoints Array of points earned by the operator for each root
* @param numberOfLeaves Array with the total number of leaves for each Merkle tree
* @param leafIndices Array of leaf indices for the operator in each Merkle tree
* @param proofs Array of positional Merkle proofs for each claim
* @param proofs Array of sorted-hash Merkle proofs for each claim
* @dev Only callable by the AVS (Service Manager)
*/
function claimRewardsBatch(
@ -173,7 +173,7 @@ contract RewardsRegistry is RewardsRegistryStorage {
}
/**
* @notice Internal function to validate a claim and calculate rewards using Substrate/Snowbridge positional Merkle proofs.
* @notice Internal function to validate a claim and calculate rewards using sorted-hash Merkle proofs.
* @param operatorAddress Address of the operator to receive rewards
* @param rootIndex Index of the merkle root to claim from
* @param operatorPoints Points earned by the operator
@ -210,7 +210,7 @@ contract RewardsRegistry is RewardsRegistryStorage {
abi.encodePacked(leafAccount, ScaleCodec.encodeU32(uint32(operatorPoints)));
bytes32 substrateLeaf = keccak256(preimage);
bool ok = SubstrateMerkleProof.verify(
bool ok = SortedMerkleProof.verify(
merkleRootHistory[rootIndex], substrateLeaf, leafIndex, numberOfLeaves, proof
);
if (!ok) revert InvalidMerkleProof();

View file

@ -24,7 +24,7 @@ contract RewardsRegistryTest is AVSDeployer {
bytes32[] public invalidProof;
// Events
event RewardsMerkleRootUpdated(bytes32 oldRoot, bytes32 newRoot, uint256 newRootIndex);
event RewardsMerkleRootUpdated(bytes32 oldRoot, bytes32 indexed newRoot, uint256 newRootIndex);
event RewardsClaimedForIndex(
address indexed operatorAddress,
uint256 indexed rootIndex,
@ -50,7 +50,7 @@ contract RewardsRegistryTest is AVSDeployer {
leafIndex = 0; // Position of our leaf in the tree
numberOfLeaves = 2; // Simple tree with 2 leaves
// For Substrate-compatible Merkle proofs, we need to use SCALE encoding
// For sorted-hash Merkle proofs, we need to use SCALE encoding
// Our leaf (the one we want to prove exists in the tree)
bytes memory preimage =
abi.encodePacked(operatorAddress, ScaleCodec.encodeU32(uint32(operatorPoints)));
@ -61,9 +61,10 @@ contract RewardsRegistryTest is AVSDeployer {
abi.encodePacked(address(0x1234), ScaleCodec.encodeU32(uint32(50)));
bytes32 siblingLeaf = keccak256(siblingPreimage);
// For Substrate positional merkle proof, we construct the root based on position
// Since leafIndex = 0, our leaf is on the left
merkleRoot = keccak256(abi.encodePacked(leaf, siblingLeaf));
// For sorted-hash merkle proof, smaller hash goes first
merkleRoot = leaf < siblingLeaf
? keccak256(abi.encodePacked(leaf, siblingLeaf))
: keccak256(abi.encodePacked(siblingLeaf, leaf));
// The proof to verify our leaf is just the sibling leaf
validProof = new bytes32[](1);
@ -73,7 +74,10 @@ contract RewardsRegistryTest is AVSDeployer {
bytes memory newSiblingPreimage =
abi.encodePacked(address(0x5678), ScaleCodec.encodeU32(uint32(75)));
bytes32 newSiblingLeaf = keccak256(newSiblingPreimage);
newMerkleRoot = keccak256(abi.encodePacked(leaf, newSiblingLeaf));
// For sorted-hash merkle proof, smaller hash goes first
newMerkleRoot = leaf < newSiblingLeaf
? keccak256(abi.encodePacked(leaf, newSiblingLeaf))
: keccak256(abi.encodePacked(newSiblingLeaf, leaf));
// An invalid proof
invalidProof = new bytes32[](1);

View file

@ -88,7 +88,7 @@ contract ServiceManagerRewardsRegistryTest is AVSDeployer {
}
function _createFirstMerkleTree() internal {
// Create first merkle tree with Substrate-compatible SCALE encoding
// Create first merkle tree with SCALE encoding
bytes memory preimage =
abi.encodePacked(operatorAddress, ScaleCodec.encodeU32(uint32(operatorPoints)));
bytes32 leaf = keccak256(preimage);
@ -97,9 +97,10 @@ contract ServiceManagerRewardsRegistryTest is AVSDeployer {
abi.encodePacked(address(0x1111), ScaleCodec.encodeU32(uint32(50)));
bytes32 siblingLeaf = keccak256(siblingPreimage);
// For Substrate positional merkle proof, we construct the root based on position
// Since leafIndex = 0, our leaf is on the left
merkleRoot = keccak256(abi.encodePacked(leaf, siblingLeaf));
// For sorted-hash merkle proof, smaller hash goes first
merkleRoot = leaf < siblingLeaf
? keccak256(abi.encodePacked(leaf, siblingLeaf))
: keccak256(abi.encodePacked(siblingLeaf, leaf));
validProof = new bytes32[](1);
validProof[0] = siblingLeaf;
}
@ -114,8 +115,10 @@ contract ServiceManagerRewardsRegistryTest is AVSDeployer {
abi.encodePacked(address(0x2222), ScaleCodec.encodeU32(uint32(75)));
bytes32 siblingLeaf = keccak256(siblingPreimage);
// Since leafIndex = 0, our leaf is on the left
secondMerkleRoot = keccak256(abi.encodePacked(leaf, siblingLeaf));
// For sorted-hash merkle proof, smaller hash goes first
secondMerkleRoot = leaf < siblingLeaf
? keccak256(abi.encodePacked(leaf, siblingLeaf))
: keccak256(abi.encodePacked(siblingLeaf, leaf));
secondValidProof = new bytes32[](1);
secondValidProof[0] = siblingLeaf;
}
@ -130,8 +133,10 @@ contract ServiceManagerRewardsRegistryTest is AVSDeployer {
abi.encodePacked(address(0x3333), ScaleCodec.encodeU32(uint32(60)));
bytes32 siblingLeaf = keccak256(siblingPreimage);
// Since leafIndex = 0, our leaf is on the left
thirdMerkleRoot = keccak256(abi.encodePacked(leaf, siblingLeaf));
// For sorted-hash merkle proof, smaller hash goes first
thirdMerkleRoot = leaf < siblingLeaf
? keccak256(abi.encodePacked(leaf, siblingLeaf))
: keccak256(abi.encodePacked(siblingLeaf, leaf));
thirdValidProof = new bytes32[](1);
thirdValidProof[0] = siblingLeaf;
}
@ -593,9 +598,10 @@ contract ServiceManagerRewardsRegistryTest is AVSDeployer {
abi.encodePacked(address(0x4444), ScaleCodec.encodeU32(uint32(80)));
bytes32 secondSiblingLeaf = keccak256(secondSiblingPreimage);
// Since leafIndex = 0, our leaf is on the left
bytes32 secondRegistryMerkleRoot =
keccak256(abi.encodePacked(secondLeaf, secondSiblingLeaf));
// For sorted-hash merkle proof, smaller hash goes first
bytes32 secondRegistryMerkleRoot = secondLeaf < secondSiblingLeaf
? keccak256(abi.encodePacked(secondLeaf, secondSiblingLeaf))
: keccak256(abi.encodePacked(secondSiblingLeaf, secondLeaf));
// Set the merkle root in the second registry
vm.prank(mockRewardsAgent);

View file

@ -79,6 +79,7 @@ pub mod pallet {
/// The sovereign account for Ethereum bridge reserves
/// This should be derived from the Ethereum location using
/// a location-to-account converter (e.g., HashedDescription)
#[pallet::constant]
type EthereumSovereignAccount: Get<Self::AccountId>;
/// The Snowbridge outbound queue for sending messages to Ethereum

Binary file not shown.

View file

@ -1,5 +1,2 @@
# Keeping this file around to remind us
# to check patch notes to see if they add things here we can use
[test]
# Sadly there isnt any global timeout options here yet
timeout = 900000 # 15 minutes in milliseconds

View file

@ -2,7 +2,7 @@ import path from "node:path";
import { $ } from "bun";
import { createClient, type PolkadotClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import invariant from "tiny-invariant";
import {
ANVIL_FUNDED_ACCOUNTS,

View file

@ -8355,7 +8355,7 @@ export const rewardsRegistryAbi = [
name: 'newRoot',
internalType: 'bytes32',
type: 'bytes32',
indexed: false,
indexed: true,
},
{
name: 'newRootIndex',

View file

@ -1,298 +0,0 @@
# Event Utilities Usage Guide
This guide demonstrates how to use event utilities for waiting and handling blockchain events in DataHaven (Substrate) and Ethereum chains.
## Overview
The event utilities provide a unified, type-safe interface for handling blockchain events with:
- **Consistent API**: Similar patterns for both DataHaven and Ethereum
- **Composable design**: Use `Promise.all()` for parallel event waiting
- **Graceful timeouts**: Functions return `null` on timeout (no errors thrown)
- **Type safety**: Full TypeScript support with proper event typing
## Quick Start
### DataHaven Events
```typescript
import { waitForDataHavenEvent } from '@test/e2e-suite/utils/datahaven';
const result = await waitForDataHavenEvent({
api: dhApi,
pallet: "Balances",
event: "Transfer",
timeout: 10000
});
if (result.data) {
console.log(`Transfer: ${result.data.amount}`);
}
```
### Ethereum Events
```typescript
import { waitForEthereumEvent } from '@test/e2e-suite/utils/ethereum';
const result = await waitForEthereumEvent({
client: publicClient,
address: tokenAddress,
abi: erc20Abi,
eventName: "Transfer",
timeout: 10000
});
if (result.log) {
console.log(`Transfer: ${result.log.args.value}`);
}
```
## DataHaven Event Handling
### Transaction Submission (Direct Events)
When you submit your own transaction, you can get immediate access to all events:
```typescript
const result = await dhApi.tx.Balances
.transfer({ dest: recipient, value: amount })
.signAndSubmit(signer);
// result type: TxFinalized
if (result.ok) {
// Access all events from the transaction
const transfer = result.events.find(
e => e.pallet === "Balances" && e.name === "Transfer"
);
if (transfer) {
console.log(`Transferred: ${transfer.value.amount}`);
}
} else {
console.error(`Failed:`, result.dispatchError);
}
```
**Use this approach when:**
- ✅ You're submitting the transaction yourself
- ✅ You need events from that specific transaction
- ✅ You want synchronous access to results
### Waiting for External Events
Use `waitForDataHavenEvent` when monitoring for events from other sources:
```typescript
const result = await waitForDataHavenEvent({
api: dhApi,
pallet: "Balances",
event: "Transfer",
filter: (e) => e.to === myAddress,
timeout: 10000
});
if (result.data) {
console.log(`Received transfer: ${result.data.amount}`);
}
```
**Use this approach when:**
- ✅ Waiting for events from other transactions
- ✅ Monitoring cross-chain events
- ✅ Watching for external activity
- ✅ Implementing time-based conditions
#### With Filtering
```typescript
const result = await waitForDataHavenEvent({
api: dhApi,
pallet: "Balances",
event: "Transfer",
timeout: 10000,
// Only match transfers from specific sender with amount > 1000
filter: (event) => event.from === senderAddress && event.amount > 1000n
});
```
#### With Callbacks
```typescript
const result = await waitForDataHavenEvent({
api: dhApi,
pallet: "Staking",
event: "Rewarded",
timeout: 30000,
// Real-time processing as events are found
onEvent: (event) => {
console.log(`✅ Reward received: ${event.amount}`);
updateRewardsDisplay(event.amount);
// Callback doesn't affect the return value
}
});
```
### Multiple Events
Wait for multiple events in parallel:
```typescript
const [transfer, reward, slash] = await Promise.all([
waitForDataHavenEvent({
api: dhApi,
pallet: "Balances",
event: "Transfer",
timeout: 10000,
filter: (e) => e.to === myAddress
}),
waitForDataHavenEvent({
api: dhApi,
pallet: "Staking",
event: "Rewarded",
timeout: 5000 // Shorter timeout for optional event
}),
waitForDataHavenEvent({
api: dhApi,
pallet: "Staking",
event: "Slashed",
timeout: 5000
})
]);
// Handle results - some may be null (timeout)
if (!transfer.data) {
throw new Error("Expected transfer not received");
}
if (reward.data) {
console.log(`Received reward: ${reward.data.amount}`);
} else {
console.log("No rewards in this period (normal)");
}
if (slash.data) {
console.warn(`Got slashed: ${slash.data.amount}`);
}
```
## Ethereum Event Handling
### Basic Usage
```typescript
const result = await waitForEthereumEvent({
client: publicClient,
address: contractAddress,
abi: contractAbi,
eventName: "StateChanged",
timeout: 30000
});
if (result.log) {
console.log(`New state: ${result.log.args.newState}`);
console.log(`Block: ${result.log.blockNumber}`);
console.log(`Tx: ${result.log.transactionHash}`);
}
```
### With Argument Filtering
Filter events by their arguments:
```typescript
const result = await waitForEthereumEvent({
client: publicClient,
address: tokenAddress,
abi: erc20Abi,
eventName: "Transfer",
// Only match specific transfers
args: {
from: myAddress, // Must be FROM myAddress
to: recipientAddress // Must be TO recipientAddress
// Omit 'value' to match any amount
},
timeout: 30000
});
```
### With Callbacks
Process events in real-time:
```typescript
const result = await waitForEthereumEvent({
client: publicClient,
address: dexAddress,
abi: dexAbi,
eventName: "Swap",
args: {
tokenIn: wethAddress,
tokenOut: usdcAddress
},
onEvent: (log) => {
const { amountIn, amountOut } = log.args;
const rate = Number(amountOut) / Number(amountIn);
console.log(`Swap at rate: ${rate}`);
console.log(`Block: ${log.blockNumber}`);
// Update UI, send notifications, etc.
updatePriceDisplay(rate);
},
timeout: 60000
});
```
## Error Handling
### Timeout Handling
Events that timeout return `null`, not an error:
```typescript
const result = await waitForDataHavenEvent({
api: dhApi,
pallet: "Staking",
event: "Rewarded",
timeout: 5000
});
if (!result.data) {
// Timeout - decide how to handle
console.log("No rewards within 5 seconds");
// Option 1: Continue (if event is optional)
// Option 2: Retry with longer timeout
// Option 3: Fail the test
throw new Error("Expected rewards not received");
}
// Safe to use result.data here
console.log(`Rewards: ${result.data.amount}`);
```
### Error vs Timeout
```typescript
try {
const result = await waitForEthereumEvent({
client: publicClient,
address: tokenAddress,
abi: erc20Abi,
eventName: "Transfer",
timeout: 10000
});
if (!result.log) {
// Timeout - not an error
console.log("No transfer within 10 seconds");
// Handle based on your needs
} else {
// Event found
processTransfer(result.log);
}
} catch (error) {
// Only actual errors reach here:
// - Network issues
// - Invalid parameters
// - Contract/API errors
console.error("Unexpected error:", error);
}
```

View file

@ -1,18 +1,21 @@
import { datahaven } from "@polkadot-api/descriptors";
import { createClient as createPapiClient, type PolkadotClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { ANVIL_FUNDED_ACCOUNTS, type DataHavenApi, logger } from "utils";
import {
type Account,
createPublicClient,
createWalletClient,
fallback,
http,
type PublicClient,
type WalletClient
type WalletClient,
webSocket
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { anvil } from "viem/chains";
import { socketClientCache } from "viem/utils";
import type { LaunchNetworkResult } from "../launcher";
export interface TestConnectors {
@ -42,10 +45,17 @@ export class ConnectorFactory {
async createTestConnectors(): Promise<TestConnectors> {
logger.debug("Creating test connectors...");
// Prefer WebSocket for event-heavy public client; fall back to HTTP when WS is unavailable.
const wsUrl = this.connectors.ethereumWsUrl;
const publicTransport = wsUrl?.startsWith("ws")
? fallback([webSocket(wsUrl), http(this.connectors.ethereumRpcUrl)])
: http(this.connectors.ethereumRpcUrl);
// Create Ethereum clients
const publicClient = createPublicClient({
chain: anvil,
transport: http(this.connectors.ethereumRpcUrl)
transport: publicTransport
});
const account = privateKeyToAccount(ANVIL_FUNDED_ACCOUNTS[0].privateKey);
@ -93,9 +103,42 @@ export class ConnectorFactory {
async cleanup(connectors: TestConnectors): Promise<void> {
logger.debug("Cleaning up test connectors...");
// Close any cached WebSocket clients used by viem to prevent reconnect noise after teardown.
try {
for (const client of socketClientCache.values()) {
try {
client.close();
} catch {
// Ignore individual socket close errors
}
}
socketClientCache.clear();
} catch {
// Ignore cache errors during cleanup
}
// Destroy PAPI client
if (connectors.papiClient) {
connectors.papiClient.destroy();
try {
connectors.papiClient.destroy();
} catch (error) {
// Ignore DisjointError - it occurs when ChainHead subscriptions are already disjointed
// This is harmless and expected during cleanup
const errorName = error instanceof Error ? error.name : String(error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (
errorName === "DisjointError" ||
errorName.includes("disjoint") ||
errorMessage.includes("disjoint") ||
errorMessage.includes("ChainHead disjointed")
) {
// Ignore - this is expected and harmless
} else {
// Re-throw unexpected errors
throw error;
}
}
}
logger.debug("Test connectors cleaned up");

View file

@ -2,7 +2,7 @@ import { secp256k1 } from "@noble/curves/secp256k1";
import { $ } from "bun";
import { createClient, type PolkadotClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { cargoCrossbuild } from "scripts/cargo-crossbuild";
import invariant from "tiny-invariant";
import {

View file

@ -303,6 +303,21 @@ export const registerServices = async (
launchedNetwork.elRpcUrl = elRpcUrl;
logger.info(`📝 Execution Layer RPC URL configured: ${elRpcUrl}`);
// Configure EL WebSocket URL
try {
const rethWsPort = await getPortFromKurtosis("el-1-reth-lodestar", "ws", enclaveName);
if (rethWsPort && rethWsPort > 0) {
const elWsUrl = `ws://127.0.0.1:${rethWsPort}`;
launchedNetwork.elWsUrl = elWsUrl;
logger.info(`📝 Execution Layer WebSocket URL configured: ${elWsUrl}`);
} else {
logger.warn("⚠️ EL WebSocket port not found, WebSocket will not be available");
}
} catch (error) {
logger.warn(`⚠️ Could not determine EL WebSocket port: ${error}`);
// Don't throw - WebSocket is optional, HTTP fallback will be used
}
// Configure CL Endpoint
const lodestarPublicPort = await getPortFromKurtosis("cl-1-lodestar-reth", "http", enclaveName);
const clEndpoint = `http://127.0.0.1:${lodestarPublicPort}`;

View file

@ -274,10 +274,13 @@ export const launchNetwork = async (
// Return connectors
const aliceContainerName = `datahaven-alice-${networkId}`;
const wsPort = launchedNetwork.getContainerPort(aliceContainerName);
// Use the WebSocket URL from LaunchedNetwork (set by registerServices from Kurtosis)
const ethereumWsUrl = launchedNetwork.elWsUrl;
return {
launchedNetwork,
dataHavenRpcUrl: `http://127.0.0.1:${wsPort}`,
ethereumRpcUrl: launchedNetwork.elRpcUrl,
ethereumWsUrl,
ethereumClEndpoint: launchedNetwork.clEndpoint,
cleanup
};

View file

@ -3,7 +3,7 @@ import { datahaven } from "@polkadot-api/descriptors";
import { $ } from "bun";
import { createClient, type PolkadotClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import invariant from "tiny-invariant";
import {
ANVIL_FUNDED_ACCOUNTS,
@ -631,6 +631,8 @@ const launchRelayerContainers = async (
const isLocal = relayerImageTag.endsWith(":local");
const networkName = launchedNetwork.networkName;
invariant(networkName, "❌ Docker network name not found in LaunchedNetwork instance");
const restartArgs = ["--restart", "on-failure:5"];
logger.info(`🔁 Relayer restart policy enabled: ${restartArgs.join(" ")}`);
for (const { configFilePath, name, config, pk } of relayersToStart) {
try {
@ -652,6 +654,7 @@ const launchRelayerContainers = async (
containerName,
"--network",
networkName,
...restartArgs,
...(isLocal ? [] : ["--pull", "always"])
];

View file

@ -23,6 +23,7 @@ export interface LaunchNetworkResult {
launchedNetwork: LaunchedNetwork;
dataHavenRpcUrl: string;
ethereumRpcUrl: string;
ethereumWsUrl?: string;
ethereumClEndpoint: string;
cleanup: () => Promise<void>;
}

View file

@ -19,6 +19,8 @@ export class LaunchedNetwork {
protected _activeRelayers: RelayerType[];
/** The RPC URL for the Ethereum Execution Layer (EL) client. */
protected _elRpcUrl?: string;
/** The WebSocket URL for the Ethereum Execution Layer (EL) client. */
protected _elWsUrl?: string;
/** The HTTP endpoint for the Ethereum Consensus Layer (CL) client. */
protected _clEndpoint?: string;
/** The Kubernetes namespace for the network. Used only for deploy commands. */
@ -33,6 +35,7 @@ export class LaunchedNetwork {
this._networkName = "";
this._networkId = "";
this._elRpcUrl = undefined;
this._elWsUrl = undefined;
this._clEndpoint = undefined;
this._kubeNamespace = undefined;
this._datahavenAuthorities = undefined;
@ -113,6 +116,22 @@ export class LaunchedNetwork {
return this._elRpcUrl;
}
/**
* Sets the WebSocket URL for the Ethereum Execution Layer (EL) client.
* @param url - The EL WebSocket URL string.
*/
public set elWsUrl(url: string) {
this._elWsUrl = url;
}
/**
* Gets the WebSocket URL for the Ethereum Execution Layer (EL) client.
* @returns The EL WebSocket URL string, or undefined if not set.
*/
public get elWsUrl(): string | undefined {
return this._elWsUrl;
}
/**
* Sets the HTTP endpoint for the Ethereum Consensus Layer (CL) client.
* @param url - The CL HTTP endpoint string.

View file

@ -1,19 +1,7 @@
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 {
ANVIL_FUNDED_ACCOUNTS,
type Deployments,
getValidatorInfoByName,
logger,
type TestAccounts
} from "utils";
import { ANVIL_FUNDED_ACCOUNTS, logger } from "utils";
import { privateKeyToAccount } from "viem/accounts";
/**
@ -23,11 +11,6 @@ export interface ValidatorOptions {
rpcUrl: string;
}
export interface ValidatorOptionsExt extends ValidatorOptions {
connectors: TestConnectors;
deployments: Deployments;
}
/**
* Funds validators with tokens and ETH.
*
@ -104,186 +87,3 @@ export const updateValidatorSet = async (options: ValidatorOptions): Promise<voi
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;
}

View file

@ -2,7 +2,7 @@ import { datahaven } from "@polkadot-api/descriptors";
import { Binary } from "@polkadot-api/substrate-bindings";
import { createClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { generateRandomAccount, getEvmEcdsaSigner, logger, printDivider, printHeader } from "utils";
import { createWalletClient, defineChain, http, parseEther, publicActions } from "viem";
import { privateKeyToAccount } from "viem/accounts";

View file

@ -2,7 +2,7 @@ import { parseArgs } from "node:util";
import { datahaven } from "@polkadot-api/descriptors";
import { createClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { getEvmEcdsaSigner, logger, SUBSTRATE_FUNDED_ACCOUNTS } from "utils";
import { parseJsonToParameters } from "utils/types";

View file

@ -1,12 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import invariant from "tiny-invariant";
import {
getValidatorInfoByName,
logger,
runShellCommandWithLogger,
TestAccounts
} from "../utils/index";
import { logger, runShellCommandWithLogger } from "../utils/index";
interface SetupValidatorsOptions {
rpcUrl: string;
@ -100,16 +95,15 @@ export const setupValidators = async (options: SetupValidatorsOptions): Promise<
}
}
const validators = [
getValidatorInfoByName(config, TestAccounts.Alice),
getValidatorInfoByName(config, TestAccounts.Bob)
];
// Filter to only alice and bob validators
const validatorsToRegister = config.validators.filter((v) =>
["alice", "bob"].includes((v.solochainAuthorityName || "").toLowerCase())
);
logger.info(`🔎 Registering ${validators.length} validators`);
logger.info(`🔎 Registering ${validatorsToRegister.length} validators`);
// Iterate through validators to register them
for (let i = 0; i < validators.length; i++) {
const validator = validators[i];
for (const [i, validator] of validatorsToRegister.entries()) {
logger.info(`🔧 Setting up validator ${i} (${validator.publicKey})`);
const env = {

View file

@ -16,110 +16,109 @@ import { Binary } from "@polkadot-api/substrate-bindings";
import { FixedSizeBinary } from "polkadot-api";
import {
ANVIL_FUNDED_ACCOUNTS,
CROSS_CHAIN_TIMEOUTS,
getPapiSigner,
logger,
parseDeploymentsFile,
SUBSTRATE_FUNDED_ACCOUNTS,
waitForDataHavenEvent,
waitForEthereumEvent
waitForEthereumEvent,
ZERO_ADDRESS
} from "utils";
import { decodeEventLog, encodeAbiParameters, parseEther } from "viem";
import { decodeEventLog, encodeAbiParameters, erc20Abi, parseEther, parseEventLogs } from "viem";
import { gatewayAbi } from "../contract-bindings";
import { BaseTestSuite } from "../framework";
import type { TestConnectors } from "../framework/connectors";
// Constants
// The actual Ethereum sovereign account used by the runtime (derived from runtime configuration)
const ETHEREUM_SOVEREIGN_ACCOUNT = "0xd8030FB68Aa5B447caec066f3C0BdE23E6db0a05";
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Dynamic values fetched from runtime
let ethereumSovereignAccount: string;
let nativeTokenId: `0x${string}` | null = null;
interface BalanceSnapshot {
dh: bigint;
sovereign: bigint;
erc20: bigint;
}
async function getBalanceSnapshot(
connectors: Pick<TestConnectors, "dhApi" | "publicClient">,
opts: { dhAccount?: string; ethAccount?: `0x${string}`; erc20Address?: `0x${string}` }
): Promise<BalanceSnapshot> {
const { dhApi, publicClient } = connectors;
const { dhAccount, ethAccount, erc20Address } = opts;
const [dhBalance, sovereignBalance, erc20Balance] = await Promise.all([
dhAccount ? dhApi.query.System.Account.getValue(dhAccount) : null,
dhApi.query.System.Account.getValue(ethereumSovereignAccount),
erc20Address && ethAccount
? publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "balanceOf",
args: [ethAccount]
})
: 0n
]);
return {
dh: dhBalance?.data.free ?? 0n,
sovereign: sovereignBalance.data.free,
erc20: erc20Balance as bigint
};
}
function expectBalanceDeltas(
before: BalanceSnapshot,
after: BalanceSnapshot,
expected: { dhMin?: bigint; dhExact?: bigint; sovereign?: bigint; erc20?: bigint }
): void {
if (expected.dhMin !== undefined) {
const decrease = before.dh - after.dh;
expect(decrease).toBeGreaterThanOrEqual(expected.dhMin);
expect(decrease - expected.dhMin).toBeLessThan(parseEther("0.01")); // tx fee sanity check
}
if (expected.dhExact !== undefined) {
expect(after.dh - before.dh).toBe(expected.dhExact);
}
if (expected.sovereign !== undefined) {
expect(after.sovereign - before.sovereign).toBe(expected.sovereign);
}
if (expected.erc20 !== undefined) {
expect(after.erc20 - before.erc20).toBe(expected.erc20);
}
}
let deployments: any;
// Minimal ERC20 ABI for reading token metadata and Transfer events
const ERC20_ABI = [
{
inputs: [],
name: "name",
outputs: [{ name: "", type: "string" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "symbol",
outputs: [{ name: "", type: "string" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "decimals",
outputs: [{ name: "", type: "uint8" }],
stateMutability: "view",
type: "function"
},
{
inputs: [{ name: "account", type: "address" }],
name: "balanceOf",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "totalSupply",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
type: "event",
name: "Transfer",
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "value", type: "uint256", indexed: false }
]
},
{
inputs: [
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" }
],
name: "approve",
outputs: [{ name: "", type: "bool" }],
stateMutability: "nonpayable",
type: "function"
}
] as const;
async function getNativeERC20Address(connectors: any): Promise<`0x${string}` | null> {
if (!deployments) throw new Error("Global deployments not initialized");
if (!nativeTokenId) return null;
// The actual token ID that gets registered by the runtime
// This is computed by the runtime's TokenIdOf converter which uses
// DescribeGlobalPrefix to encode the reanchored location
const tokenId =
"0x68c3bfa36acaeb2d97b73d1453652c6ef27213798f88842ec3286846e8ee4d3a" as `0x${string}`;
// Query the ERC20 address from Gateway using the token ID from registration
const tokenAddress = (await connectors.publicClient.readContract({
address: deployments.Gateway,
address: deployments!.Gateway,
abi: gatewayAbi,
functionName: "tokenAddressOf",
args: [tokenId]
args: [nativeTokenId]
})) as `0x${string}`;
return tokenAddress === ZERO_ADDRESS ? null : tokenAddress;
}
async function requireNativeERC20Address(connectors: any): Promise<`0x${string}`> {
const address = await getNativeERC20Address(connectors);
if (!address) {
throw new Error(
`Native ERC20 address not available (nativeTokenId=${nativeTokenId ?? "null"}). ` +
`Did the 'register DataHaven native token on Ethereum' test succeed?`
);
}
return address;
}
class NativeTokenTransferTestSuite extends BaseTestSuite {
constructor() {
super({
suiteName: "native-token-transfer",
networkOptions: {
slotTime: 1
}
});
super({ suiteName: "native-token-transfer" });
this.setupHooks();
}
}
@ -134,463 +133,306 @@ describe("Native Token Transfer", () => {
beforeAll(async () => {
alithSigner = getPapiSigner("ALITH");
deployments = await parseDeploymentsFile();
});
it("should register DataHaven native token on Ethereum", async () => {
const connectors = suite.getTestConnectors();
// First, check if token is already registered
const existingTokenAddress = await getNativeERC20Address(connectors);
expect(existingTokenAddress).toBeNull();
ethereumSovereignAccount = await (
connectors.dhApi.constants as any
).DataHavenNativeTransfer.EthereumSovereignAccount();
logger.debug(`Ethereum sovereign account: ${ethereumSovereignAccount}`);
});
// Register token via sudo
const registerTx = connectors.dhApi.tx.SnowbridgeSystemV2.register_token({
sender: { type: "V5", value: { parents: 0, interior: { type: "Here", value: undefined } } },
asset_id: { type: "V5", value: { parents: 0, interior: { type: "Here", value: undefined } } },
metadata: {
name: Binary.fromText("STAGE"),
symbol: Binary.fromText("wSTAGE"),
decimals: 18
}
});
it(
"should register DataHaven native token on Ethereum",
async () => {
const connectors = suite.getTestConnectors();
// Create and sign the transaction
const sudoTx = connectors.dhApi.tx.Sudo.sudo({
call: registerTx.decodedCall
});
// Ensure token is not already deployed (nativeTokenId is null until registered)
expect(await getNativeERC20Address(connectors)).toBeNull();
// Submit transaction and wait for both DataHaven confirmation and Ethereum event
const [ethEventResult, dhTxResult] = await Promise.all([
// Wait for the token registration event on Ethereum Gateway (start watcher first)
waitForEthereumEvent({
const fromBlock = await connectors.publicClient.getBlockNumber();
// Build transaction to register token
const sudoTx = connectors.dhApi.tx.Sudo.sudo({
call: connectors.dhApi.tx.SnowbridgeSystemV2.register_token({
sender: {
type: "V5",
value: { parents: 0, interior: { type: "Here", value: undefined } }
},
asset_id: {
type: "V5",
value: { parents: 0, interior: { type: "Here", value: undefined } }
},
metadata: {
name: Binary.fromText("HAVE"),
symbol: Binary.fromText("wHAVE"),
decimals: 18
}
}).decodedCall
});
const dhTxResult = await sudoTx.signAndSubmit(alithSigner);
expect(dhTxResult.ok).toBe(true);
// Verify token IDs match across chains and store for subsequent tests
const registerEvent = dhTxResult.events.find(
(e: any) => e.type === "SnowbridgeSystemV2" && e.value?.type === "RegisterToken"
);
expect(registerEvent).toBeDefined();
nativeTokenId = registerEvent!.value.value.foreign_token_id.asHex();
logger.debug(`Native token ID: ${nativeTokenId}`);
// Wait for cross-chain confirmation after we have the token ID (and filter by it).
const ethEvent = await waitForEthereumEvent({
client: connectors.publicClient,
address: deployments.Gateway,
address: deployments!.Gateway,
abi: gatewayAbi,
eventName: "ForeignTokenRegistered",
timeout: 300_000 // set appropriately
}),
// Submit and wait for transaction on DataHaven
sudoTx.signAndSubmit(alithSigner)
]);
args: { tokenID: nativeTokenId },
fromBlock: fromBlock > 0n ? fromBlock - 1n : fromBlock,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS
});
// Verify DataHaven transaction succeeded
expect(dhTxResult.ok).toBe(true);
const { args: ethTokenEvent } = decodeEventLog({
abi: gatewayAbi,
eventName: "ForeignTokenRegistered",
data: ethEvent.data,
topics: ethEvent.topics
}) as { args: { tokenID: string; token: `0x${string}` } };
// Verify the Ethereum event was received
expect(ethEventResult.log).not.toBeNull();
expect(ethTokenEvent.tokenID).toBe(nativeTokenId!);
// Check for events in the DataHaven transaction result
const { events } = dhTxResult;
// Verify ERC20 metadata
const deployedERC20 = ethTokenEvent.token;
logger.debug(`DataHaven native token deployed at: ${deployedERC20}`);
const sudoEvent = events.find((e: any) => e.type === "Sudo" && e.value.type === "Sudid");
expect(sudoEvent).toBeDefined();
const [name, symbol, decimals] = await Promise.all([
connectors.publicClient.readContract({
address: deployedERC20,
abi: erc20Abi,
functionName: "name"
}),
connectors.publicClient.readContract({
address: deployedERC20,
abi: erc20Abi,
functionName: "symbol"
}),
connectors.publicClient.readContract({
address: deployedERC20,
abi: erc20Abi,
functionName: "decimals"
})
]);
// Find SnowbridgeSystemV2.RegisterToken event
const registerTokenEvent = events.find(
(e: any) => e.type === "SnowbridgeSystemV2" && e.value.type === "RegisterToken"
);
expect(registerTokenEvent).toBeDefined();
expect(name).toBe("HAVE");
expect(symbol).toBe("wHAVE");
expect(decimals).toBe(18);
},
CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS + CROSS_CHAIN_TIMEOUTS.EVENT_CONFIRMATION_MS
);
const tokenIdRaw = registerTokenEvent?.value?.value?.foreign_token_id;
expect(tokenIdRaw).toBeDefined();
const tokenId = tokenIdRaw.asHex();
it(
"should transfer tokens from DataHaven to Ethereum",
async () => {
const connectors = suite.getTestConnectors();
const eventArgs = (ethEventResult.log as any)?.args;
expect(eventArgs?.tokenID).toBe(tokenId);
const erc20Address = await requireNativeERC20Address(connectors);
// Get the deployed token address from the event
const deployedERC20Address = eventArgs?.token as `0x${string}`;
expect(deployedERC20Address).not.toBe(ZERO_ADDRESS);
// Set up transfer parameters
const recipient = ANVIL_FUNDED_ACCOUNTS[0].publicKey;
const amount = parseEther("100");
const fee = parseEther("1");
logger.debug(`ERC20 token deployed at: ${deployedERC20Address}`);
// Capture initial balances
const before = await getBalanceSnapshot(connectors, {
dhAccount: SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey,
ethAccount: recipient,
erc20Address
});
const [tokenName, tokenSymbol, tokenDecimals] = await Promise.all([
connectors.publicClient.readContract({
address: deployedERC20Address,
abi: ERC20_ABI,
functionName: "name"
}) as Promise<string>,
connectors.publicClient.readContract({
address: deployedERC20Address,
abi: ERC20_ABI,
functionName: "symbol"
}) as Promise<string>,
connectors.publicClient.readContract({
address: deployedERC20Address,
abi: ERC20_ABI,
functionName: "decimals"
}) as Promise<number>
]);
expect(tokenName).toBe("STAGE");
expect(tokenSymbol).toBe("wSTAGE");
expect(tokenDecimals).toBe(18);
}, 300_000); // 5 minute timeout for registration
it("should transfer tokens from DataHaven to Ethereum", async () => {
const connectors = suite.getTestConnectors();
// Get the deployed token address
const maybeErc20 = await getNativeERC20Address(connectors);
expect(maybeErc20).not.toBeNull();
const erc20Address = maybeErc20 as `0x${string}`;
const recipient = ANVIL_FUNDED_ACCOUNTS[0].publicKey;
const amount = parseEther("100");
const fee = parseEther("1");
// Get initial balances including sovereign account
const initialDHBalance = await connectors.dhApi.query.System.Account.getValue(
SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
);
const initialSovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
const initialWrappedHaveBalance = (await connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [recipient]
})) as bigint;
// Perform transfer
const tx = connectors.dhApi.tx.DataHavenNativeTransfer.transfer_to_ethereum({
recipient: FixedSizeBinary.fromHex(recipient) as FixedSizeBinary<20>,
amount,
fee
});
// Submit transaction and wait for both DataHaven confirmation and Ethereum minting event
logger.debug("Waiting for Ethereum minting event (this may take several minutes)...");
const [tokenMintEvent, txResult] = await Promise.all([
// Wait for the mint event on Ethereum (start watcher first)
waitForEthereumEvent({
client: connectors.publicClient,
address: erc20Address,
abi: ERC20_ABI,
eventName: "Transfer",
args: {
from: ZERO_ADDRESS, // Minting from zero address
to: recipient
},
timeout: 300_000 // 5 minutes should be sufficient
}),
// Submit and wait for transaction on DataHaven
tx.signAndSubmit(alithSigner)
]);
// Check transaction result for errors
expect(txResult.ok).toBe(true);
// Extract events directly from transaction result
const tokenTransferEvent = txResult.events.find(
(e: any) =>
e.type === "DataHavenNativeTransfer" &&
e.value?.type === "TokensTransferredToEthereum" &&
e.value?.value?.from === SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
);
const tokensLockedEvent = txResult.events.find(
(e: any) =>
e.type === "DataHavenNativeTransfer" &&
e.value?.type === "TokensLocked" &&
e.value?.value?.account === SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
);
// Verify DataHaven events were received
expect(tokenTransferEvent).toBeDefined();
expect(tokenTransferEvent?.value?.value).toBeDefined();
expect(tokensLockedEvent).toBeDefined();
expect(tokensLockedEvent?.value?.value).toBeDefined();
logger.debug("DataHaven event confirmed, message should be queued for relayers");
// Check sovereign account balance after block finalization
const intermediateBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
logger.debug(`Sovereign balance after events: ${intermediateBalance.data.free}`);
// Get final balances including sovereign account
const finalDHBalance = await connectors.dhApi.query.System.Account.getValue(
SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
);
const finalSovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
const finalWrappedHaveBalance = (await connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [recipient]
})) as bigint;
// If Ethereum event was not received, provide diagnostic information
// Verify results only if Ethereum event was received
if (tokenMintEvent.log) {
// Verify user balance decreased by amount + fee + transaction fee
expect(finalDHBalance.data.free).toBeLessThan(initialDHBalance.data.free);
const dhDecrease = initialDHBalance.data.free - finalDHBalance.data.free;
// Calculate the transaction fee from the actual balance change
const txFee = dhDecrease - (amount + fee);
// Verify the total decrease is at least the amount + fee
expect(dhDecrease).toBeGreaterThanOrEqual(amount + fee);
// Verify the transaction fee is reasonable (less than 0.01 HAVE)
expect(txFee).toBeLessThan(parseEther("0.01"));
expect(txFee).toBeGreaterThan(0n);
// Verify sovereign account balance increased by exactly the amount (not the fee)
const sovereignIncrease = finalSovereignBalance.data.free - initialSovereignBalance.data.free;
expect(sovereignIncrease).toBe(amount);
// Verify wrapped token balance increased by the amount
expect(finalWrappedHaveBalance).toBeGreaterThan(initialWrappedHaveBalance);
const wrappedHaveIncrease = finalWrappedHaveBalance - initialWrappedHaveBalance;
expect(wrappedHaveIncrease).toBe(amount);
} else {
// Compact diagnostics and fail the test with a helpful message
const dhDecrease = initialDHBalance.data.free - finalDHBalance.data.free;
const sovereignIncrease = finalSovereignBalance.data.free - initialSovereignBalance.data.free;
const ethBalanceChange = finalWrappedHaveBalance - initialWrappedHaveBalance;
const summary = `Ethereum mint event not observed within timeout. DHΔ=${dhDecrease}, SovereignΔ=${sovereignIncrease}, ERC20Δ=${ethBalanceChange}`;
logger.warn(summary);
expect(tokenMintEvent.log).not.toBeNull();
}
}, 420_000); // 7 minute timeout
it("should maintain 1:1 backing ratio", async () => {
const connectors = suite.getTestConnectors();
// Get the deployed token address
const maybeErc20 = await getNativeERC20Address(connectors);
expect(maybeErc20).not.toBeNull();
const erc20Address = maybeErc20 as `0x${string}`;
const totalSupply = (await connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "totalSupply"
})) as bigint;
const sovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
expect(sovereignBalance.data.free).toBeGreaterThanOrEqual(totalSupply);
});
it("should transfer tokens from Ethereum to DataHaven", async () => {
const connectors = suite.getTestConnectors();
// Resolve deployed ERC20 for native token; if missing, register via sudo
const maybeErc20 = await getNativeERC20Address(connectors);
expect(maybeErc20).not.toBeNull();
const erc20Address = maybeErc20 as `0x${string}`;
// Use shared wallet client from connectors
const ethWalletClient = connectors.walletClient;
const ethereumSender = ethWalletClient.account.address as `0x${string}`;
// Destination on DataHaven is ALITH (AccountId20)
const dhRecipient = SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey as `0x${string}`;
const amount = parseEther("5");
// v2 fees in ETH
const executionFee = parseEther("0.1");
const relayerFee = parseEther("0.4");
// Ensure sender has enough wrapped tokens on Ethereum; if not, fund via DH -> ETH transfer
let currentEthTokenBalance = (await connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [ethereumSender]
})) as bigint;
if (currentEthTokenBalance < amount) {
const mintAmount = amount - currentEthTokenBalance;
const fee = parseEther("0.01");
// Build transfer transaction
const tx = connectors.dhApi.tx.DataHavenNativeTransfer.transfer_to_ethereum({
recipient: FixedSizeBinary.fromHex(ethereumSender) as FixedSizeBinary<20>,
amount: mintAmount,
recipient: FixedSizeBinary.fromHex(recipient) as FixedSizeBinary<20>,
amount,
fee
});
// Start watcher first and submit in parallel; look back one block for safety
// Submit transaction and wait for cross-chain confirmation
const startBlock = await connectors.publicClient.getBlockNumber();
const fromBlock = startBlock > 0n ? startBlock - 1n : startBlock;
const [mintEvent, txResult] = await Promise.all([
waitForEthereumEvent({
client: connectors.publicClient,
address: erc20Address,
abi: ERC20_ABI,
eventName: "Transfer",
args: { from: ZERO_ADDRESS, to: ethereumSender },
fromBlock,
timeout: 300_000 // 3 minutes
const dhTxResult = await tx.signAndSubmit(alithSigner);
expect(dhTxResult.ok).toBe(true);
await waitForEthereumEvent({
client: connectors.publicClient,
address: erc20Address,
abi: erc20Abi,
eventName: "Transfer",
args: { from: ZERO_ADDRESS, to: recipient },
fromBlock: startBlock > 0n ? startBlock - 1n : startBlock,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS
});
// Verify DataHaven events
expect(
dhTxResult.events.find(
(e: any) =>
e.type === "DataHavenNativeTransfer" &&
e.value?.type === "TokensTransferredToEthereum" &&
e.value?.value?.from === SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
)
).toBeDefined();
expect(
dhTxResult.events.find(
(e: any) =>
e.type === "DataHavenNativeTransfer" &&
e.value?.type === "TokensLocked" &&
e.value?.value?.account === SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
)
).toBeDefined();
// Capture final balances
const after = await getBalanceSnapshot(connectors, {
dhAccount: SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey,
ethAccount: recipient,
erc20Address
});
// Verify balance changes
expectBalanceDeltas(before, after, {
dhMin: amount + fee,
sovereign: amount,
erc20: amount
});
// Verify 1:1 backing ratio is maintained
const totalSupply = (await connectors.publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "totalSupply"
})) as bigint;
const sovereignBalance =
await connectors.dhApi.query.System.Account.getValue(ethereumSovereignAccount);
expect(sovereignBalance.data.free).toBeGreaterThanOrEqual(totalSupply);
},
CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS + CROSS_CHAIN_TIMEOUTS.EVENT_CONFIRMATION_MS
);
it(
"should transfer tokens from Ethereum to DataHaven",
async () => {
const connectors = suite.getTestConnectors();
const erc20Address = await requireNativeERC20Address(connectors);
const ethWalletClient = connectors.walletClient;
const ethereumSender = ethWalletClient.account.address as `0x${string}`;
const dhRecipient = SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey as `0x${string}`;
const amount = parseEther("5");
const executionFee = parseEther("0.1");
const relayerFee = parseEther("0.4");
// Capture initial balances and supply for ETH -> DH leg
const [before, initialTotalSupply] = await Promise.all([
getBalanceSnapshot(connectors, {
dhAccount: dhRecipient,
ethAccount: ethereumSender,
erc20Address
}),
tx.signAndSubmit(alithSigner)
connectors.publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "totalSupply"
}) as Promise<bigint>
]);
expect(before.erc20).toBeGreaterThanOrEqual(amount);
// Send tokens to DataHaven via Gateway
const sendHash = await ethWalletClient.writeContract({
address: deployments!.Gateway as `0x${string}`,
abi: gatewayAbi,
functionName: "v2_sendMessage",
args: [
"0x" as `0x${string}`,
[
encodeAbiParameters(
[
{ name: "kind", type: "uint8" },
{ name: "token", type: "address" },
{ name: "value", type: "uint128" }
],
[0, erc20Address, amount]
)
] as any,
dhRecipient,
executionFee,
relayerFee
],
value: executionFee + relayerFee,
chain: null
});
const sendReceipt = await connectors.publicClient.waitForTransactionReceipt({
hash: sendHash
});
expect(sendReceipt.status).toBe("success");
// Assert OutboundMessageAccepted event was emitted
const gatewayLogs = sendReceipt.logs!.filter(
(log) => log.address.toLowerCase() === deployments!.Gateway.toLowerCase()
);
const decodedEvents = parseEventLogs({ abi: gatewayAbi, logs: gatewayLogs });
const hasOutboundAccepted = decodedEvents.some(
(event) => event.eventName === "OutboundMessageAccepted"
);
expect(hasOutboundAccepted).toBe(true);
// Assert ERC20 was burned (Transfer to zero address)
const erc20Logs = sendReceipt.logs!.filter(
(log) => log.address.toLowerCase() === erc20Address.toLowerCase()
);
const transferEvents = parseEventLogs({ abi: erc20Abi, logs: erc20Logs });
const burnEvent = transferEvents.find(
(event) =>
event.eventName === "Transfer" &&
event.args.from?.toLowerCase() === ethereumSender.toLowerCase() &&
event.args.to?.toLowerCase() === ZERO_ADDRESS.toLowerCase() &&
event.args.value === amount
);
expect(burnEvent).toBeDefined();
// Wait for relay (takes ~2-3 min due to Ethereum finality)
await waitForDataHavenEvent<{ account: any; amount: bigint }>({
api: connectors.dhApi,
pallet: "DataHavenNativeTransfer",
event: "TokensUnlocked",
filter: (e) =>
String(e.account).toLowerCase() === dhRecipient.toLowerCase() && e.amount === amount,
timeout: CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
});
// Final balances
const [after, finalTotalSupply] = await Promise.all([
getBalanceSnapshot(connectors, {
dhAccount: dhRecipient,
ethAccount: ethereumSender,
erc20Address
}),
connectors.publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "totalSupply"
}) as Promise<bigint>
]);
expect(txResult.ok).toBe(true);
expect(mintEvent.log).not.toBeNull();
currentEthTokenBalance = (await connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [ethereumSender]
})) as bigint;
}
// Capture initial balances and supply for ETH -> DH leg
const [initialEthTokenBalance, initialTotalSupply] = await Promise.all([
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [ethereumSender]
}) as Promise<bigint>,
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "totalSupply"
}) as Promise<bigint>
]);
expect(initialEthTokenBalance).toBeGreaterThanOrEqual(amount);
const initialDhRecipientBalance =
await connectors.dhApi.query.System.Account.getValue(dhRecipient);
const initialSovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
// Approve Gateway to pull tokens
const approveHash = await ethWalletClient.writeContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "approve",
args: [deployments.Gateway as `0x${string}`, amount],
chain: null
});
const approveReceipt = await connectors.publicClient.waitForTransactionReceipt({
hash: approveHash
});
expect(approveReceipt.status).toBe("success");
// Build Snowbridge v2 send payload
const assets = [
encodeAbiParameters(
[
{ name: "kind", type: "uint8" },
{ name: "token", type: "address" },
{ name: "value", type: "uint128" }
],
[0, erc20Address, amount]
)
];
// The claimer should be the recipient on DataHaven (dhRecipient)
// This tells the system who should receive the unlocked tokens
const claimer = dhRecipient as `0x${string}`;
logger.info(`🔑 Setting claimer to: ${claimer} (matches dhRecipient: ${dhRecipient})`);
// For now, we can use an empty XCM since the claimer field specifies the recipient
// The Snowbridge system will handle the token unlock to the claimer address
const xcm = "0x" as `0x${string}`;
// Start DH event watcher BEFORE sending Ethereum tx to avoid missing the event
logger.debug("Starting TokensUnlocked watcher on DataHaven before sending Ethereum tx...");
const dhEventPromise = waitForDataHavenEvent<{
account: any;
amount: any;
}>({
api: connectors.dhApi,
pallet: "DataHavenNativeTransfer",
event: "TokensUnlocked",
filter: (e: any) => {
const acct =
typeof e?.account === "string"
? e.account
: (e?.account?.asHex?.() ?? e?.account?.toString?.());
const amt = typeof e?.amount === "bigint" ? e.amount : BigInt(e?.amount ?? 0);
const isMatch = acct?.toLowerCase?.() === dhRecipient.toLowerCase() && amt === amount;
if (isMatch) {
logger.debug(`Matched TokensUnlocked: account=${acct}, amount=${amt}`);
}
return Boolean(isMatch);
},
timeout: 600_000
});
// Send v2_sendMessage and assert hash before awaiting all
logger.info(
`🚀 Submitting Ethereum transaction: ${amount} tokens to DataHaven recipient ${dhRecipient}`
);
const sendHash = await ethWalletClient.writeContract({
address: deployments.Gateway as `0x${string}`,
abi: gatewayAbi,
functionName: "v2_sendMessage",
args: [xcm, assets as any, claimer, executionFee, relayerFee],
value: executionFee + relayerFee,
chain: null
});
expect(sendHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
// Await both Ethereum receipt and DH TokensUnlocked event together
const [sendReceipt, dhEvent] = await Promise.all([
connectors.publicClient.waitForTransactionReceipt({ hash: sendHash }),
dhEventPromise
]);
expect(sendReceipt.status).toBe("success");
// Assert OutboundMessageAccepted from receipt logs
const hasOutboundAccepted = (sendReceipt.logs ?? []).some((log: any) => {
try {
const decoded = decodeEventLog({ abi: gatewayAbi, data: log.data, topics: log.topics });
return decoded.eventName === "OutboundMessageAccepted";
} catch {
return false;
}
});
expect(hasOutboundAccepted).toBe(true);
// Event must exist (filter already matched account and amount)
expect(dhEvent?.data).toBeDefined();
// Final balances
const [finalEthTokenBalance, finalTotalSupply] = await Promise.all([
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [ethereumSender]
}) as Promise<bigint>,
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "totalSupply"
}) as Promise<bigint>
]);
const finalDhRecipientBalance =
await connectors.dhApi.query.System.Account.getValue(dhRecipient);
const finalSovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
// Assertions: burn on Ethereum and unlock on DataHaven
expect(finalEthTokenBalance).toBe(initialEthTokenBalance - amount);
expect(finalTotalSupply).toBe(initialTotalSupply - amount);
const dhIncrease = finalDhRecipientBalance.data.free - initialDhRecipientBalance.data.free;
const sovereignDecrease = initialSovereignBalance.data.free - finalSovereignBalance.data.free;
expect(dhIncrease).toBe(amount);
expect(sovereignDecrease).toBe(amount);
}, 900_000); // 15 minute timeout for cross-chain transfers
// Assertions: burn on Ethereum and unlock on DataHaven
expect(after.erc20).toBe(before.erc20 - amount);
expect(finalTotalSupply).toBe(initialTotalSupply - amount);
expectBalanceDeltas(before, after, {
dhExact: amount, // recipient gets exactly amount
sovereign: -amount // sovereign decreases by amount (unlocked)
});
},
CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS +
CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS +
CROSS_CHAIN_TIMEOUTS.EVENT_CONFIRMATION_MS
); // includes funding (DH→ETH) + transfer (ETH→DH)
});

View file

@ -1,33 +1,10 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { logger } from "utils";
import {
type Address,
BaseError,
ContractFunctionRevertedError,
decodeErrorResult,
decodeEventLog,
type Hex,
isAddressEqual,
padHex
} from "viem";
import { CROSS_CHAIN_TIMEOUTS, logger } from "utils";
import { type Address, decodeEventLog, type Hex, isAddressEqual, padHex } from "viem";
import validatorSet from "../configs/validator-set.json";
import { BaseTestSuite } from "../framework";
import { getContractInstance, parseRewardsInfoFile } from "../utils/contracts";
import { waitForEthereumEvent } from "../utils/events";
import * as rewardsHelpers from "../utils/rewards-helpers";
// Test configuration constants
const TEST_CONFIG = {
TIMEOUTS: {
ERA_END_WAIT: 600000, // 10 minutes - increased for era transitions
MESSAGE_EXECUTION: 120000, // 2 minutes
ROOT_UPDATE: 180000, // 3 minutes
CLAIM_EVENT: 30000, // 30 seconds - increased for reliability
OVERALL_TEST: 900000 // 15 minutes - increased for full suite
},
DELAYS: {
RELAYER_INIT: 10000 // 10 seconds
}
} as const;
import { waitForDataHavenEvent, waitForEthereumEvent } from "../utils/events";
class RewardsMessageTestSuite extends BaseTestSuite {
constructor() {
@ -41,480 +18,237 @@ class RewardsMessageTestSuite extends BaseTestSuite {
const suite = new RewardsMessageTestSuite();
let rewardsRegistry!: any;
let serviceManager!: any;
let gateway!: any;
let publicClient!: any;
let dhApi!: any;
let eraIndex!: number;
let messageId!: Hex;
let merkleRoot!: Hex;
let totalPoints!: bigint;
let newRootIndex!: bigint;
let validatorProofs!: Map<string, rewardsHelpers.ValidatorProofData>;
// Persisted state from first successful claim for double-claim test
let claimedOperatorAddress!: Address;
let claimedProofData!: rewardsHelpers.ValidatorProofData;
let firstClaimGasUsed!: bigint;
let firstClaimBlockNumber!: bigint;
describe("Rewards Message Flow", () => {
beforeAll(async () => {
logger.info("Starting rewards message flow tests");
let rewardsRegistry!: any;
let serviceManager!: any;
let publicClient!: any;
let dhApi!: any;
let eraIndex!: number;
let totalPoints!: bigint;
let newRootIndex!: bigint;
// Get test connectors once for all tests
beforeAll(async () => {
const connectors = suite.getTestConnectors();
publicClient = connectors.publicClient;
dhApi = connectors.dhApi;
// Acquire core contracts once for all tests
[rewardsRegistry, serviceManager, gateway] = await Promise.all([
getContractInstance("RewardsRegistry"),
getContractInstance("ServiceManager"),
getContractInstance("Gateway")
rewardsRegistry = await getContractInstance("RewardsRegistry");
serviceManager = await getContractInstance("ServiceManager");
});
it("should verify rewards infrastructure deployment", async () => {
const rewardsInfo = await parseRewardsInfoFile();
const gateway = await getContractInstance("Gateway");
expect(rewardsRegistry.address).toBeDefined();
expect(rewardsInfo.RewardsAgent).toBeDefined();
expect(gateway.address).toBeDefined();
const [agentAddress, avsAddress] = await Promise.all([
publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "rewardsAgent",
args: []
}) as Promise<Address>,
publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "avs",
args: []
}) as Promise<Address>
]);
expect(isAddressEqual(agentAddress, rewardsInfo.RewardsAgent as Address)).toBe(true);
expect(isAddressEqual(avsAddress, serviceManager.address as Address)).toBe(true);
});
describe("Infrastructure Setup", () => {
it("should verify rewards infrastructure deployment", async () => {
// Fetch rewards info
const rewardsInfo = await parseRewardsInfoFile();
it("should wait for era end and update merkle root on Ethereum", async () => {
// Get current era and Ethereum block for event filtering
const [currentEra, fromBlock] = await Promise.all([
dhApi.query.ExternalValidators.ActiveEra.getValue(),
publicClient.getBlockNumber()
]);
expect(rewardsRegistry.address).toBeDefined();
expect(rewardsInfo.RewardsAgent).toBeDefined();
expect(gateway.address).toBeDefined();
const currentEraIndex = currentEra?.index ?? 0;
logger.debug(`Waiting for RewardsMessageSent for era ${currentEraIndex}`);
// Validate configuration
const [agentAddress, avsAddress] = await Promise.all([
publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "rewardsAgent",
args: []
}) as Promise<Address>,
publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "avs",
args: []
}) as Promise<Address>
]);
expect(isAddressEqual(agentAddress, rewardsInfo.RewardsAgent as Address)).toBe(true);
expect(isAddressEqual(avsAddress, serviceManager.address as Address)).toBe(true);
// Check DataHaven connectivity
const currentBlock = await dhApi.query.System.Number.getValue();
expect(currentBlock > 0).toBe(true);
logger.success("Rewards infrastructure verified");
const payload = await waitForDataHavenEvent<any>({
api: dhApi,
pallet: "ExternalValidatorsRewards",
event: "RewardsMessageSent",
filter: (e) => e.era_index === currentEraIndex,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS
});
});
describe("Era Transition and Message Emission", () => {
it(
"should wait for era end and capture rewards message",
async () => {
// Track current era and blocks until era end
const [currentBlock, currentEra, blocksUntilEraEnd] = await Promise.all([
dhApi.query.System.Number.getValue(),
rewardsHelpers.getCurrentEra(dhApi),
rewardsHelpers.getBlocksUntilEraEnd(dhApi)
]);
expect(payload).toBeDefined();
const merkleRoot: Hex = payload.rewards_merkle_root.asHex() as Hex;
totalPoints = payload.total_points;
eraIndex = payload.era_index;
expect(totalPoints).toBeGreaterThan(0n);
logger.info("Era transition tracking:");
logger.info(` Current block: ${currentBlock}`);
logger.info(` Current era: ${currentEra}`);
logger.info(` Blocks until era end: ${blocksUntilEraEnd}`);
// Wait for era to end and capture the rewards message event
logger.info("⏳ Waiting for era to end and rewards message to be sent...");
const timeout = blocksUntilEraEnd * 6000 + TEST_CONFIG.DELAYS.RELAYER_INIT * 3;
const rewardsMessageEvent = await rewardsHelpers.waitForRewardsMessageSent(
dhApi,
currentEra,
timeout
);
expect(rewardsMessageEvent).not.toBeNull();
if (!rewardsMessageEvent) throw new Error("Expected rewards message event to be defined");
// Store event data
messageId = rewardsMessageEvent.messageId as Hex;
merkleRoot = rewardsMessageEvent.merkleRoot as Hex;
totalPoints = rewardsMessageEvent.totalPoints;
eraIndex = rewardsMessageEvent.eraIndex;
// Validate event data
expect(messageId).toBeDefined();
expect(merkleRoot).toBeDefined();
expect(totalPoints > 0n).toBe(true);
logger.success(`Rewards message emitted for era ${eraIndex}`);
},
TEST_CONFIG.TIMEOUTS.ERA_END_WAIT
);
});
describe("Cross-Chain Message Execution", () => {
it(
"should execute rewards message on Ethereum via Gateway",
async () => {
logger.info("⏳ Waiting for message execution on Gateway...");
// Start watching from current block to avoid matching historical events
const fromBlock = await publicClient.getBlockNumber();
const executedEvent = await waitForEthereumEvent({
client: publicClient,
address: gateway.address,
abi: gateway.abi,
eventName: "MessageExecuted",
fromBlock,
timeout: TEST_CONFIG.TIMEOUTS.MESSAGE_EXECUTION
});
expect(executedEvent.log).not.toBeNull();
if (!executedEvent.log) throw new Error("Expected log to be defined");
const log = executedEvent.log;
const _decoded = decodeEventLog({
abi: gateway.abi,
data: log.data,
topics: log.topics,
eventName: "MessageExecuted"
}) as any;
logger.success("Message executed on Ethereum:");
logger.info(` Block: ${log.blockNumber}`);
logger.info(` Transaction: ${log.transactionHash}`);
},
TEST_CONFIG.TIMEOUTS.MESSAGE_EXECUTION
);
});
describe("Merkle Root Update", () => {
it(
"should update RewardsRegistry with new merkle root",
async () => {
const expectedRoot: Hex = padHex(merkleRoot, { size: 32 });
const fromBlock = await publicClient.getBlockNumber();
logger.info("⏳ Waiting for merkle root update in RewardsRegistry...");
const rootUpdatedEvent = await waitForEthereumEvent({
client: publicClient,
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
eventName: "RewardsMerkleRootUpdated",
args: { newRoot: expectedRoot },
fromBlock,
timeout: TEST_CONFIG.TIMEOUTS.ROOT_UPDATE
});
expect(rootUpdatedEvent.log).not.toBeNull();
if (!rootUpdatedEvent.log) throw new Error("Expected log to be defined");
const rootLog = rootUpdatedEvent.log;
const rootDecoded = decodeEventLog({
abi: rewardsRegistry.abi,
data: rootLog.data,
topics: rootLog.topics
}) as { args: { oldRoot: Hex; newRoot: Hex; newRootIndex: bigint } };
const updateArgs = rootDecoded.args;
// Store the new root index for claiming tests
newRootIndex = updateArgs.newRootIndex;
logger.success("Merkle root updated:");
logger.info(` Index: ${updateArgs.newRootIndex}`);
logger.info(` Old root: ${updateArgs.oldRoot}`);
logger.info(` New root: ${updateArgs.newRoot}`);
// Verify the stored root matches the expected root
const storedRoot: Hex = (await publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "merkleRootHistory",
args: [updateArgs.newRootIndex]
})) as Hex;
expect(storedRoot.toLowerCase()).toEqual(updateArgs.newRoot.toLowerCase());
expect(storedRoot.toLowerCase()).toEqual(expectedRoot.toLowerCase());
},
TEST_CONFIG.TIMEOUTS.ROOT_UPDATE
);
});
describe("Merkle Proof Generation", () => {
it("should generate valid merkle proofs for all validators", async () => {
logger.info(`📊 Generating merkle proofs for era ${eraIndex}...`);
// Get era reward points and generate proofs in parallel
const [eraPoints, proofMap] = await Promise.all([
rewardsHelpers.getEraRewardPoints(dhApi, eraIndex),
rewardsHelpers.generateMerkleProofsForEra(dhApi, eraIndex)
]);
expect(eraPoints).toBeDefined();
if (!eraPoints) throw new Error("Expected era points to be defined");
expect(eraPoints.total > 0).toBe(true);
expect(proofMap.size > 0).toBe(true);
// Store proofs for claiming tests
validatorProofs = proofMap;
logger.success("Generated merkle proofs");
// Validate proof data structure (spot check)
const firstProofMaybe = validatorProofs.values().next().value;
expect(firstProofMaybe).toBeDefined();
if (!firstProofMaybe) throw new Error("Expected first proof to be defined");
const firstProof = firstProofMaybe;
expect(firstProof.proof).toBeDefined();
expect(firstProof.points > 0).toBe(true);
expect(firstProof.numberOfLeaves > 0).toBe(true);
// Wait for RewardsMerkleRootUpdated
const expectedRoot: Hex = padHex(merkleRoot, { size: 32 });
const rootUpdatedEvent = await waitForEthereumEvent({
client: publicClient,
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
eventName: "RewardsMerkleRootUpdated",
args: { newRoot: expectedRoot },
fromBlock,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS
});
const rootDecoded = decodeEventLog({
abi: rewardsRegistry.abi,
data: rootUpdatedEvent.data,
topics: rootUpdatedEvent.topics
}) as { args: { oldRoot: Hex; newRoot: Hex; newRootIndex: bigint } };
// Store the new root index for claiming tests
newRootIndex = rootDecoded.args.newRootIndex;
// Verify the stored root matches
const storedRoot: Hex = (await publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "merkleRootHistory",
args: [newRootIndex]
})) as Hex;
expect(storedRoot.toLowerCase()).toEqual(expectedRoot.toLowerCase());
});
describe("Rewards Claiming", () => {
it("should fund RewardsRegistry for payouts", async () => {
logger.info("💰 Funding RewardsRegistry for reward payouts...");
it("should successfully claim rewards for validator", async () => {
// Fund RewardsRegistry for reward payouts
const { walletClient: fundingWallet } = suite.getTestConnectors();
const fundingTx = await fundingWallet.sendTransaction({
to: rewardsRegistry.address as Address,
value: totalPoints,
chain: null
});
const fundingReceipt = await publicClient.waitForTransactionReceipt({ hash: fundingTx });
expect(fundingReceipt.status).toBe("success");
const { walletClient: fundingWallet } = suite.getTestConnectors();
const fundingAmount = totalPoints;
// Get era reward points and pick first validator
const rewardPoints =
await dhApi.query.ExternalValidatorsRewards.RewardPointsForEra.getValue(eraIndex);
expect(rewardPoints).toBeDefined();
expect(rewardPoints.total).toBeGreaterThan(0);
const fundingTx = await fundingWallet.sendTransaction({
to: rewardsRegistry.address as Address,
value: fundingAmount,
const [validatorAccount, points] = rewardPoints.individual[0];
// Generate merkle proof via runtime API
const merkleProof = await dhApi.apis.ExternalValidatorsRewardsApi.generate_rewards_merkle_proof(
String(validatorAccount),
eraIndex
);
expect(merkleProof).toBeDefined();
// Get validator credentials and create operator wallet
const factory = suite.getConnectorFactory();
const match = validatorSet.validators.find(
(v) => v.solochainAddress.toLowerCase() === String(validatorAccount).toLowerCase()
);
const operatorWallet = factory.createWalletClient(match!.privateKey as `0x${string}`);
const resolvedOperator: Address = operatorWallet.account.address;
// Ensure the ServiceManager maps the operator ETH address to the solochain address
const expectedSolochain = String(validatorAccount) as Address;
const mappedSolochain = (await publicClient.readContract({
address: serviceManager.address as Address,
abi: serviceManager.abi,
functionName: "validatorEthAddressToSolochainAddress",
args: [resolvedOperator]
})) as Address;
if (mappedSolochain.toLowerCase() !== expectedSolochain.toLowerCase()) {
const updateTx = await operatorWallet.writeContract({
address: serviceManager.address as Address,
abi: serviceManager.abi,
functionName: "updateSolochainAddressForValidator",
args: [expectedSolochain],
chain: null
});
const updateReceipt = await publicClient.waitForTransactionReceipt({ hash: updateTx });
expect(updateReceipt.status).toBe("success");
}
const fundingReceipt = await publicClient.waitForTransactionReceipt({ hash: fundingTx });
expect(fundingReceipt.status).toBe("success");
// Ensure claim not already recorded
const claimedBefore = (await publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "hasClaimedByIndex",
args: [resolvedOperator, newRootIndex]
})) as boolean;
expect(claimedBefore).toBe(false);
// Verify contract balance
const contractBalance = await publicClient.getBalance({
address: rewardsRegistry.address
});
// Record balances for validation
const operatorBalanceBefore = await publicClient.getBalance({ address: resolvedOperator });
const registryBalanceBefore = BigInt(
await publicClient.getBalance({ address: rewardsRegistry.address as Address })
);
expect(contractBalance > 0n).toBe(true);
logger.success("RewardsRegistry funded:");
logger.info(` Amount: ${fundingAmount} wei`);
logger.info(` Transaction: ${fundingTx}`);
logger.info(` Contract balance: ${contractBalance} wei`);
// Submit claim transaction
const claimTx = await operatorWallet.writeContract({
address: serviceManager.address as Address,
abi: serviceManager.abi,
functionName: "claimOperatorRewards",
chain: null,
args: [
0, // strategy index
newRootIndex,
BigInt(points),
BigInt(merkleProof.number_of_leaves),
BigInt(merkleProof.leaf_index),
merkleProof.proof.map((node: { asHex: () => string }) => node.asHex()) as readonly Hex[]
]
});
it(
"should successfully claim rewards for validator",
async () => {
logger.info("🎯 Claiming rewards for validator...");
// Ensure prerequisites
expect(validatorProofs).toBeDefined();
expect(newRootIndex).toBeDefined();
if (newRootIndex === undefined) {
throw new Error("Merkle root not updated yet; cannot claim rewards");
}
// Select first validator to claim
const firstEntry = validatorProofs.entries().next();
expect(firstEntry.value).toBeDefined();
if (!firstEntry.value) throw new Error("Expected entry to be defined");
const entry = firstEntry.value;
const [, proofData] = entry;
// Get validator credentials and create operator wallet
const factory = suite.getConnectorFactory();
const credentials = rewardsHelpers.getValidatorCredentials(proofData.validatorAccount);
expect(credentials.privateKey).toBeDefined();
if (!credentials.privateKey) throw new Error("missing validator private key");
const operatorWallet = factory.createWalletClient(credentials.privateKey as `0x${string}`);
const resolvedOperator: Address = operatorWallet.account.address;
// Record initial balance for validation
const balanceBefore = await publicClient.getBalance({ address: resolvedOperator });
// Submit claim transaction
const claimTx = await operatorWallet.writeContract({
address: serviceManager.address as Address,
abi: serviceManager.abi,
functionName: "claimOperatorRewards",
chain: null,
args: [
0, // strategy index
newRootIndex,
BigInt(proofData.points),
BigInt(proofData.numberOfLeaves),
BigInt(proofData.leafIndex),
proofData.proof as readonly Hex[]
]
});
logger.info(`📝 Claim transaction submitted: ${claimTx}`);
// Wait for transaction confirmation
const claimReceipt = await publicClient.waitForTransactionReceipt({ hash: claimTx });
expect(claimReceipt.status).toBe("success");
// Persist state for the double-claim test
claimedOperatorAddress = resolvedOperator;
claimedProofData = proofData;
firstClaimGasUsed = claimReceipt.gasUsed;
firstClaimBlockNumber = claimReceipt.blockNumber;
// Wait for and validate claim event
const claimEvent = await waitForEthereumEvent({
client: publicClient,
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
eventName: "RewardsClaimedForIndex",
fromBlock: claimReceipt.blockNumber - 1n,
timeout: TEST_CONFIG.TIMEOUTS.CLAIM_EVENT
});
expect(claimEvent.log).toBeDefined();
if (!claimEvent.log) throw new Error("Expected log to be defined");
const claimLog = claimEvent.log;
const claimDecoded = decodeEventLog({
abi: rewardsRegistry.abi,
data: claimLog.data,
topics: claimLog.topics
}) as {
args: {
operatorAddress: Address;
rootIndex: bigint;
points: bigint;
rewardsAmount: bigint;
};
};
const claimArgs = claimDecoded.args;
// Validate claim event data
expect(isAddressEqual(claimArgs.operatorAddress, resolvedOperator)).toBe(true);
expect(claimArgs.rootIndex).toEqual(newRootIndex);
expect(claimArgs.points).toEqual(BigInt(proofData.points));
expect(claimArgs.rewardsAmount > 0n).toBe(true);
logger.success("Rewards claimed successfully:");
logger.info(` Operator: ${resolvedOperator}`);
logger.info(` Points: ${claimArgs.points}`);
logger.info(` Rewards: ${claimArgs.rewardsAmount} wei`);
logger.info(` Root index: ${claimArgs.rootIndex}`);
// Validate balance change accounting for gas costs
const balanceAfter = await publicClient.getBalance({ address: resolvedOperator });
const actualBalanceIncrease = balanceAfter - balanceBefore;
const gasUsedWei = claimReceipt.gasUsed * claimReceipt.effectiveGasPrice;
const adjustedIncrease = actualBalanceIncrease + gasUsedWei;
logger.info("💰 Balance validation:");
logger.info(` Gas used: ${gasUsedWei} wei`);
logger.info(` Adjusted balance increase: ${adjustedIncrease} wei`);
expect(BigInt(adjustedIncrease)).toEqual(claimArgs.rewardsAmount);
expect(claimArgs.rewardsAmount).toEqual(BigInt(proofData.points));
},
TEST_CONFIG.TIMEOUTS.CLAIM_EVENT
// Wait for transaction confirmation
const claimReceipt = await publicClient.waitForTransactionReceipt({ hash: claimTx });
expect(claimReceipt.status).toBe("success");
logger.debug(
`Claim tx type: ${claimReceipt.type}, effectiveGasPrice: ${claimReceipt.effectiveGasPrice}, gasUsed: ${claimReceipt.gasUsed}`
);
it(
"should prevent double claiming of rewards",
async () => {
logger.info("🚫 Testing double-claim prevention (on-chain revert)...");
// Decode and validate claim event from receipt
const claimLog = claimReceipt.logs.find(
(log: { address: string }) =>
log.address.toLowerCase() === rewardsRegistry.address.toLowerCase()
)!;
const { args: claimArgs } = decodeEventLog({
abi: rewardsRegistry.abi,
data: claimLog.data,
topics: claimLog.topics
}) as {
args: { operatorAddress: Address; rootIndex: bigint; points: bigint; rewardsAmount: bigint };
};
// Preconditions from previous test
expect(claimedProofData).toBeDefined();
expect(claimedOperatorAddress).toBeDefined();
expect(firstClaimGasUsed).toBeDefined();
expect(firstClaimBlockNumber).toBeDefined();
expect(newRootIndex).toBeDefined();
if (newRootIndex === undefined) throw new Error("Merkle root not updated yet");
expect(isAddressEqual(claimArgs.operatorAddress, resolvedOperator)).toBe(true);
expect(claimArgs.rootIndex).toEqual(newRootIndex);
expect(claimArgs.points).toEqual(BigInt(points));
expect(claimArgs.rewardsAmount).toBeGreaterThan(0n);
const factory = suite.getConnectorFactory();
const credentials = rewardsHelpers.getValidatorCredentials(
claimedProofData.validatorAccount
);
if (!credentials.privateKey) throw new Error("missing validator private key");
const operatorWallet = factory.createWalletClient(credentials.privateKey as `0x${string}`);
const claimedAfter = (await publicClient.readContract({
address: rewardsRegistry.address,
abi: rewardsRegistry.abi,
functionName: "hasClaimedByIndex",
args: [resolvedOperator, newRootIndex]
})) as boolean;
expect(claimedAfter).toBe(true);
// Send a real transaction expected to revert. Provide explicit gas to avoid estimation/simulation.
const gasLimit = firstClaimGasUsed + 100_000n;
const revertTxHash = await operatorWallet.writeContract({
address: serviceManager.address as Address,
abi: serviceManager.abi,
functionName: "claimOperatorRewards",
args: [
0,
newRootIndex,
BigInt(claimedProofData.points),
BigInt(claimedProofData.numberOfLeaves),
BigInt(claimedProofData.leafIndex),
claimedProofData.proof as readonly Hex[]
],
gas: gasLimit,
chain: null
});
const revertReceipt = await publicClient.waitForTransactionReceipt({ hash: revertTxHash });
expect(revertReceipt.status).toBe("reverted");
// Verify custom error using eth_call at the same block
let decodedErrorName = "";
try {
await publicClient.simulateContract({
account: operatorWallet.account,
address: serviceManager.address as Address,
abi: serviceManager.abi,
functionName: "claimOperatorRewards",
args: [
0,
newRootIndex,
BigInt(claimedProofData.points),
BigInt(claimedProofData.numberOfLeaves),
BigInt(claimedProofData.leafIndex),
claimedProofData.proof as readonly Hex[]
],
blockNumber: revertReceipt.blockNumber
});
throw new Error("Expected simulateContract to revert");
} catch (err: any) {
if (err instanceof BaseError) {
const revertError = err.walk((e) => e instanceof ContractFunctionRevertedError);
if (revertError instanceof ContractFunctionRevertedError) {
// First try viem's decoded data (only works if ABI included the error)
decodedErrorName = revertError.data?.errorName ?? "";
// Fallback: decode the raw revert data using an ABI that includes the custom error
if (!decodedErrorName) {
const rawData = revertError.raw as Hex | undefined;
if (rawData) {
try {
const unionAbi = [
...(serviceManager.abi as any[]),
...(rewardsRegistry.abi as any[])
];
const decoded = decodeErrorResult({ abi: unionAbi as any, data: rawData });
decodedErrorName = decoded.errorName;
} catch (_e) {
// ignore secondary decode errors
}
}
}
} else {
throw err;
}
} else {
throw err;
}
}
expect(decodedErrorName).toBe("RewardsAlreadyClaimedForIndex");
logger.success(
"Double-claim prevention verified (on-chain revert and correct custom error)"
);
},
TEST_CONFIG.TIMEOUTS.CLAIM_EVENT
// Validate RewardsRegistry balance decrease matches claimed rewards
const registryBalanceAfter = BigInt(
await publicClient.getBalance({ address: rewardsRegistry.address as Address })
);
expect(registryBalanceBefore - registryBalanceAfter).toEqual(claimArgs.rewardsAmount);
expect(claimArgs.rewardsAmount).toEqual(BigInt(points));
// Validate operator received rewards (accounting for gas)
const operatorBalanceAfter = await publicClient.getBalance({ address: resolvedOperator });
const gasCost = BigInt(claimReceipt.gasUsed) * BigInt(claimReceipt.effectiveGasPrice);
const netBalanceChange = BigInt(operatorBalanceAfter) - BigInt(operatorBalanceBefore);
// Operator balance should have changed by: rewards - gasCost
expect(netBalanceChange + gasCost).toEqual(claimArgs.rewardsAmount);
});
});

View file

@ -9,322 +9,169 @@
* - Observe `ExternalValidators.ExternalValidatorsSet` on DataHaven (substrate), confirming propagation.
*/
import { beforeAll, describe, expect, it } from "bun:test";
import { getOwnerAccount } from "launcher/validators";
import {
addValidatorToAllowlist,
getOwnerAccount,
isValidatorInAllowlist,
registerSingleOperator,
serviceManagerHasOperator
} from "launcher/validators";
import {
CROSS_CHAIN_TIMEOUTS,
type Deployments,
getValidatorInfoByName,
isValidatorNodeRunning,
getValidator,
isValidatorRunning,
launchDatahavenValidator,
logger,
parseDeploymentsFile,
TestAccounts,
type ValidatorInfo
registerOperator,
ZERO_ADDRESS
} 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";
import { BaseTestSuite, type TestConnectors } from "../framework";
class ValidatorSetUpdateTestSuite extends BaseTestSuite {
constructor() {
super({
suiteName: "validator-set-update",
networkOptions: {
slotTime: 1,
blockscout: false
}
suiteName: "validator-set-update"
});
this.setupHooks();
}
override async onSetup(): Promise<void> {
logger.info("Waiting for cross-chain infrastructure to stabilize...");
// Launch two new nodes to be authorities
logger.debug("Launching Charlie and Dave validators...");
// 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
});
const { launchedNetwork } = this.getConnectors();
await Promise.all([
launchDatahavenValidator("charlie", { launchedNetwork }),
launchDatahavenValidator("dave", { 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;
let connectors: TestConnectors;
describe("Validator Set Update", () => {
// Validator sets loaded from external JSON
let initialValidators: ValidatorInfo[] = [];
let newValidators: ValidatorInfo[] = [];
const initialValidators = [getValidator("alice"), getValidator("bob")];
const newValidators = [getValidator("charlie"), getValidator("dave")];
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;
}
connectors = suite.getTestConnectors();
});
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());
it("should verify test environment", async () => {
const networkId = suite.getNetworkId();
const { publicClient, papiClient } = connectors;
expect(isAliceRunning).toBe(true);
expect(isBobRunning).toBe(true);
expect(isCharlieRunning).toBe(true);
expect(isDaveRunning).toBe(true);
});
// 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);
it("should verify initial test setup", async () => {
const connectors = suite.getTestConnectors();
// Chain connectivity
expect(await publicClient.getBlockNumber()).toBeGreaterThan(0);
expect((await papiClient.getBlockHeader()).number).toBeGreaterThan(0);
// 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
// Contract deployed
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({
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}`]
});
expect(solochainAddress.toLowerCase()).toBe(validator.solochainAddress.toLowerCase());
logger.success(`Validator ${validator.publicKey} mapped to ${solochainAddress}`);
}
// 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 verify new validators are not yet registered", async () => {
const connectors = suite.getTestConnectors();
it("should allowlist and register new validators as operators", async () => {
const opts = { connectors, deployments };
// Verify that new validators are not yet registered
for (const validator of newValidators) {
const solochainAddress = await connectors.publicClient.readContract({
// 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();
};
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()
);
const [charlieAllowlisted, daveAllowlisted, charlieRegistered, daveRegistered] =
await Promise.all([
isAllowlisted("charlie"),
isAllowlisted("dave"),
isRegistered("charlie"),
isRegistered("dave")
]);
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();
it(
"should send updated validator set and verify on DataHaven",
async () => {
const { publicClient, walletClient, dhApi } = connectors;
// 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({
// Send the updated validator set via Snowbridge
const hash = await walletClient.writeContract({
address: deployments.ServiceManager as `0x${string}`,
abi: dataHavenServiceManagerAbi,
functionName: "sendNewValidatorSet",
args: [executionFee, relayerFee],
value: totalValue,
args: [parseEther("0.1"), parseEther("0.2")],
value: parseEther("0.3"),
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 receipt = await publicClient.waitForTransactionReceipt({ hash });
expect(receipt.status).toBe("success");
// Verify OutboundMessageAccepted event was emitted
const hasOutboundAccepted = (receipt.logs ?? []).some((log: any) => {
try {
const decoded = decodeEventLog({ abi: gatewayAbi, data: log.data, topics: log.topics });
@ -333,57 +180,24 @@ describe("Validator Set Update", () => {
return false;
}
});
expect(hasOutboundAccepted).toBe(true);
if (hasOutboundAccepted) {
logger.success("OutboundMessageAccepted event found in transaction receipt!");
} else {
throw new Error("OutboundMessageAccepted event not found in transaction receipt");
// Wait for the validator set to be updated on Substrate
await waitForDataHavenEvent({
api: dhApi,
pallet: "ExternalValidators",
event: "ExternalValidatorsSet",
timeout: CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
});
// 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);
}
} 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.warn("ExternalValidatorsSet event not observed; will rely on storage check.");
} else {
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);
},
CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
);
});

View file

@ -1,5 +1,7 @@
export const DEFAULT_SUBSTRATE_WS_PORT = 9944;
export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
export const ANVIL_FUNDED_ACCOUNTS = {
0: {
publicKey: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
@ -54,6 +56,40 @@ export const CONTAINER_NAMES = {
"blockscout-fe": "blockscout-frontend--"
} as const;
/**
* Cross-chain timing breakdown (E2E config: 1s ETH slots, fast-runtime DH)
*
* DH ETH (typical: 13 min, timeout: 8 min)
*
* 1. Message queued in outbound-queue instant
* 2. Block finalized (GRANDPA) ~6s (1 DH block)
* 3. Wait for next BEEFY commitment 048s
* - min_block_delta = 8 blocks × 6s = 48s max
* 4. BEEFY relay picks up commitment ~6s (session-bound scanning)
* 5. BeefyClient.randaoCommitDelay wait ~4s (4 ETH blocks × 1s)
* 6. Submit BEEFY proof to Ethereum ~2s (2 blocks for tx inclusion)
* 7. Solochain relay submits message proof ~2s (tx inclusion + polling)
* Total: ~20s best case, ~70s typical, up to 120s with variance
*
* ETH DH (typical: 12 min, timeout: 6 min)
*
* 1. Transaction included in ETH block ~2s (2 blocks)
* 2. Wait for beacon finality ~64s (64 slots × 1s)
* 3. Beacon relay updates light client 030s
* - updateSlotInterval = 30 slots × 1s = 30s cadence
* 4. Execution relay polls for messages ~10s
* 5. Message submitted to DataHaven ~6s (1 DH block)
* Total: ~80s best case, ~120s typical, up to 180s with variance
*/
export const CROSS_CHAIN_TIMEOUTS = {
/** DH → ETH message relay (8 min) */
DH_TO_ETH_MS: 8 * 60 * 1000,
/** ETH → DH message relay (6 min, beacon finality dominates) */
ETH_TO_DH_MS: 6 * 60 * 1000,
/** On-chain event confirmation (30s) */
EVENT_CONFIRMATION_MS: 30 * 1000
} as const;
export const SUBSTRATE_FUNDED_ACCOUNTS = {
ALITH: {
publicKey: "0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac",

View file

@ -1,262 +1,97 @@
import { firstValueFrom, of } from "rxjs";
import { catchError, map, filter as rxFilter, take, tap, timeout } from "rxjs/operators";
import { firstValueFrom } from "rxjs";
import { filter as rxFilter, take, timeout } from "rxjs/operators";
import type { Abi, Address, Log, PublicClient } from "viem";
import { decodeEventLog } from "viem";
import { logger } from "./logger";
import type { DataHavenApi } from "./papi";
/**
* Event utilities for DataHaven and Ethereum chains
*
* This module provides utilities for waiting for events on different chains:
* - DataHaven events (substrate-based chain events)
* - Ethereum events (using viem event filters)
*/
/**
* Result from waiting for a DataHaven event
*/
export interface DataHavenEventResult<T = unknown> {
/** Pallet name */
pallet: string;
/** Event name */
event: string;
/** Event data payload (null if timeout or error) */
data: T | null;
/** Metadata about when/where event was emitted */
meta: any | null;
}
/**
* Options for waiting for a single DataHaven event
*/
export interface WaitForDataHavenEventOptions<T = unknown> {
/** DataHaven API instance */
api: DataHavenApi;
/** Pallet name (e.g., "System", "Balances") */
pallet: string;
/** Event name (e.g., "ExtrinsicSuccess", "Transfer") */
event: string;
/** Optional filter function to match specific events */
filter?: (event: T) => boolean;
/** Timeout in milliseconds (default: 30000) */
/** Default: 30000ms */
timeout?: number;
/** Callback for matched event */
onEvent?: (event: T) => void;
/** Callback for timeout */
failOnTimeout?: boolean;
}
/**
* Wait for a specific event on the DataHaven chain
* @param options - Options for event waiting
* @returns Event result with pallet, event name, and converted data
*/
/** Waits for a DataHaven chain event. Throws on timeout. */
export async function waitForDataHavenEvent<T = unknown>(
options: WaitForDataHavenEventOptions<T>
): Promise<DataHavenEventResult<T>> {
const {
api,
pallet,
event,
filter,
timeout: timeoutMs = 30000,
onEvent,
failOnTimeout
} = options;
): Promise<T> {
const { api, pallet, event, filter, timeout: timeoutMs = 30000 } = options;
const eventWatcher = (api.event as any)?.[pallet]?.[event];
if (!eventWatcher?.watch) {
logger.warn(`Event ${pallet}.${event} not found`);
return { pallet, event, data: null, meta: null };
const watcher = (api.event as any)?.[pallet]?.[event];
if (!watcher?.watch) {
throw new Error(`Event ${pallet}.${event} not found in API`);
}
let meta: any = null;
let data: T | null = null;
const result = (await firstValueFrom(
watcher.watch().pipe(
rxFilter(({ payload }: { payload: T }) => (filter ? filter(payload) : true)),
take(1),
timeout({
first: timeoutMs,
with: () => {
throw new Error(`Timeout waiting for ${pallet}.${event} after ${timeoutMs}ms`);
}
})
)
)) as { payload: T };
try {
const matched: any = await firstValueFrom(
eventWatcher.watch().pipe(
// Log every raw emission from the watcher
tap(() => {
logger.debug(`Event ${pallet}.${event} received (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 event
take(1),
// Enforce an overall timeout while waiting for a matching event
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);
}
}),
catchError((error: unknown) => {
logger.error(`Error in event subscription ${pallet}.${event}: ${error}`);
return of(null);
})
)
);
if (matched) {
meta = matched.meta;
data = matched.payload as T;
if (data) {
onEvent?.(data);
}
}
} catch (error) {
logger.error(`Unexpected error waiting for event ${pallet}.${event}: ${error}`);
data = null;
}
return { pallet, event, data, meta };
return result.payload;
}
// ================== Ethereum Event Utilities ==================
/**
* Result from waiting for an Ethereum event
*/
export interface EthereumEventResult {
/** Contract address */
address: Address;
/** Event name */
eventName: string;
/** Event log (null if timeout or error) */
log: Log | null;
}
/**
* Options for waiting for a single Ethereum event
*/
export interface WaitForEthereumEventOptions<TAbi extends Abi = Abi> {
/** Viem public client instance */
client: PublicClient;
/** Contract address */
address: Address;
/** Contract ABI */
abi: TAbi;
/** Event name to watch for */
eventName: any;
/** Optional event arguments to filter */
/** Only indexed event parameters can be filtered */
args?: any;
/** Timeout in milliseconds (default: 30000) */
/** Default: 30000ms */
timeout?: number;
/** Include events from past blocks */
fromBlock?: bigint;
/** Callback for each matched event */
onEvent?: (log: Log) => void;
}
/**
* Wait for a specific event on the Ethereum chain
* @param options - Options for event waiting
* @returns Event result with address, event name, and log
*/
/** Waits for an Ethereum event, throws on timeout. */
export async function waitForEthereumEvent<TAbi extends Abi = Abi>(
options: WaitForEthereumEventOptions<TAbi>
): Promise<EthereumEventResult> {
const { client, address, abi, eventName, args, timeout = 30000, fromBlock, onEvent } = options;
): Promise<Log> {
const { client, address, abi, eventName, args, timeout = 30000, fromBlock } = options;
const log = await new Promise<Log | null>((resolve) => {
let unwatch: (() => void) | null = null;
let timeoutId: NodeJS.Timeout | null = null;
let matchedLog: Log | null = null;
let unwatch: () => void = () => {};
let timeoutId!: Timer;
const cleanup = () => {
if (unwatch) {
unwatch();
const eventPromise = new Promise<Log>((resolve, reject) => {
unwatch = client.watchContractEvent({
address,
abi,
eventName,
args,
fromBlock,
onLogs: (logs) => {
if (logs.length === 0) return;
logger.debug(`Ethereum event ${eventName} received: ${logs.length} logs`);
resolve(logs[0]);
},
onError: (error) => {
logger.error(`Error watching Ethereum event ${eventName} from ${address}: ${error}`);
reject(error);
}
if (timeoutId) clearTimeout(timeoutId);
};
// Set up timeout
timeoutId = setTimeout(() => {
logger.debug(`Timeout waiting for Ethereum event ${eventName} after ${timeout}ms`);
cleanup();
resolve(matchedLog);
}, timeout);
// Watch for events
try {
unwatch = client.watchContractEvent({
address,
abi,
eventName,
args,
fromBlock,
onLogs: (logs) => {
logger.debug(`Ethereum event ${eventName} received: ${logs.length} logs`);
// If args include non-indexed fields, viem cannot pre-filter them.
// Post-filter by decoding logs and matching provided args if any.
let selected: Log | null = null;
if (args && Object.keys(args).length > 0) {
for (const candidate of logs) {
try {
const decoded = decodeEventLog({
abi,
eventName: eventName as any,
data: candidate.data,
topics: candidate.topics
});
const decodedArgs = (decoded as any).args ?? {};
const allMatch = Object.entries(args as Record<string, unknown>).every(
([key, value]) => decodedArgs?.[key] === value
);
if (allMatch) {
selected = candidate;
break;
}
} catch {
// Ignore decode errors and continue scanning
}
}
}
if (!selected && (!args || Object.keys(args).length === 0) && logs.length > 0) {
// Only fallback to first log when no args filter provided
selected = logs[0];
}
if (selected) {
matchedLog = selected;
if (onEvent) {
onEvent(matchedLog);
}
cleanup();
resolve(matchedLog);
}
// If no selected log matched, keep watching until timeout
},
onError: (error: unknown) => {
// Log and continue; transient watcher errors shouldn't abort the wait
logger.error(`Error watching Ethereum event ${eventName}: ${error}`);
cleanup();
resolve(null);
}
});
} catch (error) {
logger.error(`Failed to watch Ethereum event ${eventName}: ${error}`);
cleanup();
resolve(null);
}
});
});
return { address, eventName, log };
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() =>
reject(new Error(`Timeout waiting for ${eventName} from ${address} after ${timeout}ms`)),
timeout
);
});
try {
return await Promise.race([eventPromise, timeoutPromise]);
} finally {
clearTimeout(timeoutId);
unwatch();
}
}

View file

@ -4,7 +4,7 @@ import { datahaven } from "@polkadot-api/descriptors";
import { createClient, type PolkadotClient, type TypedApi } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getPolkadotSigner, type PolkadotSigner } from "polkadot-api/signer";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { SUBSTRATE_FUNDED_ACCOUNTS } from "./constants";
import type { Prettify } from "./types";

View file

@ -1,228 +0,0 @@
import validatorSet from "../configs/validator-set.json";
import { waitForDataHavenEvent } from "./events";
import { logger } from "./logger";
import type { DataHavenApi } from "./papi";
// Small hex helper
const toHex = (x: unknown): `0x${string}` => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyX: any = x as any;
if (anyX?.asHex) return anyX.asHex();
const s = anyX?.toString?.() ?? "";
return `0x${s}` as `0x${string}`;
};
// External Validators Rewards Events (normalized)
export interface RewardsMessageSent {
messageId: `0x${string}`;
merkleRoot: `0x${string}`;
eraIndex: number;
totalPoints: bigint;
inflation: bigint;
}
// Era tracking utilities
export async function getCurrentEra(dhApi: DataHavenApi): Promise<number> {
// Get the active era from ExternalValidators pallet
const activeEra = await dhApi.query.ExternalValidators.ActiveEra.getValue();
// ActiveEra can be null at chain genesis
if (!activeEra) {
return 0;
}
return activeEra.index;
}
export function getEraLengthInBlocks(dhApi: DataHavenApi): number {
// Read constants directly from runtime metadata
const consts: any = (dhApi as unknown as { consts?: unknown }).consts ?? {};
const epochDuration = Number(consts?.Babe?.EpochDuration ?? 10); // blocks per session
const sessionsPerEra = Number(consts?.ExternalValidators?.SessionsPerEra ?? 1);
return epochDuration * sessionsPerEra;
}
export async function getBlocksUntilEraEnd(dhApi: DataHavenApi): Promise<number> {
const currentBlock = await dhApi.query.System.Number.getValue();
const eraLength = getEraLengthInBlocks(dhApi) || 10;
const mod = currentBlock % eraLength;
return mod === 0 ? eraLength : eraLength - mod;
}
// Validator monitoring and rewards data
export interface EraRewardPoints {
total: number;
individual: Map<string, number>;
}
export async function getEraRewardPoints(
dhApi: DataHavenApi,
eraIndex: number
): Promise<EraRewardPoints | null> {
try {
const rewardPoints =
await dhApi.query.ExternalValidatorsRewards.RewardPointsForEra.getValue(eraIndex);
if (!rewardPoints) {
return null;
}
// Convert the storage format to our interface
const individual = new Map<string, number>();
for (const [account, points] of rewardPoints.individual) {
individual.set(account.toString(), points);
}
return {
total: rewardPoints.total,
individual
};
} catch (error) {
logger.error(`Failed to get era reward points for era ${eraIndex}: ${error}`);
return null;
}
}
// Merkle proof generation using DataHaven runtime API
export interface ValidatorProofData {
validatorAccount: string;
operatorAddress: string;
points: number;
proof: string[];
leaf: string;
numberOfLeaves: number;
leafIndex: number;
}
export async function generateMerkleProofForValidator(
dhApi: DataHavenApi,
validatorAccount: string,
eraIndex: number
): Promise<{ proof: string[]; leaf: string; numberOfLeaves: number; leafIndex: number } | null> {
try {
// Call the runtime API to generate merkle proof
const merkleProof = await dhApi.apis.ExternalValidatorsRewardsApi.generate_rewards_merkle_proof(
validatorAccount,
eraIndex
);
if (!merkleProof) {
logger.debug(
`No merkle proof available for validator ${validatorAccount} in era ${eraIndex}`
);
return null;
}
// Convert the proof to hex strings
const proof = merkleProof.proof.map((node: unknown) => toHex(node));
const leaf = toHex(merkleProof.leaf);
const numberOfLeaves = Number(merkleProof.number_of_leaves as bigint);
const leafIndex = Number(merkleProof.leaf_index as bigint);
return { proof, leaf, numberOfLeaves, leafIndex };
} catch (error) {
logger.error(`Failed to generate merkle proof for validator ${validatorAccount}: ${error}`);
return null;
}
}
/**
* Validator credentials containing operator address and private key
*/
export interface ValidatorCredentials {
operatorAddress: `0x${string}`;
privateKey: `0x${string}` | null;
}
/**
* Gets validator credentials (operator address and private key) by solochain address
* @param validatorAccount The validator's solochain address
* @returns The validator's credentials including operator address and private key
*/
export function getValidatorCredentials(validatorAccount: string): ValidatorCredentials {
const normalizedAccount = validatorAccount.toLowerCase();
// Find matching validator by solochain address
const match = validatorSet.validators.find(
(v) => v.solochainAddress.toLowerCase() === normalizedAccount
);
if (match) {
return {
operatorAddress: match.publicKey as `0x${string}`,
privateKey: match.privateKey as `0x${string}`
};
}
// Fallback: assume the input is already an Ethereum address, but no private key available
logger.debug(`No mapping found for ${validatorAccount}, using as-is without private key`);
return {
operatorAddress: validatorAccount as `0x${string}`,
privateKey: null
};
}
// Generate merkle proofs for all validators in an era
export async function generateMerkleProofsForEra(
dhApi: DataHavenApi,
eraIndex: number
): Promise<Map<string, ValidatorProofData>> {
// Get era reward points
const eraPoints = await getEraRewardPoints(dhApi, eraIndex);
if (!eraPoints) {
logger.warn(`No reward points found for era ${eraIndex}`);
return new Map();
}
const entries = await Promise.all(
[...eraPoints.individual].map(async ([validatorAccount, points]) => {
const merkleData = await generateMerkleProofForValidator(dhApi, validatorAccount, eraIndex);
if (!merkleData) return null;
const credentials = getValidatorCredentials(validatorAccount);
const value: ValidatorProofData = {
validatorAccount,
operatorAddress: credentials.operatorAddress,
points,
proof: merkleData.proof,
leaf: merkleData.leaf,
numberOfLeaves: merkleData.numberOfLeaves,
leafIndex: merkleData.leafIndex
};
return [credentials.operatorAddress, value] as const;
})
);
const filtered = entries.filter(Boolean) as [string, ValidatorProofData][];
const proofs = new Map(filtered);
logger.info(`Generated ${proofs.size} merkle proofs for era ${eraIndex}`);
return proofs;
}
// Rewards message event -> normalized return
export async function waitForRewardsMessageSent(
dhApi: DataHavenApi,
expectedEra?: number,
timeout = 120000
): Promise<RewardsMessageSent | null> {
const result = await waitForDataHavenEvent({
api: dhApi,
pallet: "ExternalValidatorsRewards",
event: "RewardsMessageSent",
filter: expectedEra !== undefined ? (event: any) => event.era_index === expectedEra : undefined,
timeout
});
if (!result?.data) return null;
const data: any = result.data;
return {
messageId: data.message_id.asHex(),
merkleRoot: data.rewards_merkle_root.asHex(),
eraIndex: data.era_index,
totalPoints: data.total_points,
inflation: data.inflation_amount
};
}

View file

@ -1,165 +0,0 @@
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;
}
});
}

View file

@ -1,82 +1,21 @@
/**
* 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 {
allocationManagerAbi,
dataHavenServiceManagerAbi,
delegationManagerAbi
} from "contract-bindings";
import type { TestConnectors } from "framework";
import { type Deployments, logger, waitForContainerToStart } from "utils";
import { DEFAULT_SUBSTRATE_WS_PORT } from "utils/constants";
import { getPublicPort } from "utils/docker";
import { privateKeyToAccount } from "viem/accounts";
import validatorSet from "../configs/validator-set.json";
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;
}
import { getOwnerAccount } from "../launcher/validators";
export const COMMON_LAUNCH_ARGS = [
"--unsafe-force-node-key-generation",
@ -91,154 +30,134 @@ export const COMMON_LAUNCH_ARGS = [
"--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;
};
/** Checks if a DataHaven validator container is running */
export const isValidatorRunning = async (name: string, networkId: string) =>
(await $`docker ps -q -f name=^datahaven-${name}-${networkId}`.text()).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
*/
/** Launches a single DataHaven validator node on demand */
export const launchDatahavenValidator = async (
name: TestAccounts,
options: LaunchValidatorOptions
): Promise<LaunchedValidatorInfo> => {
name: string,
options: { launchedNetwork: LaunchedNetwork; datahavenImageTag?: string }
): Promise<void> => {
const { launchedNetwork, datahavenImageTag = "datahavenxyz/datahaven:local" } = options;
const nodeId = name.toLowerCase();
const networkId = options.launchedNetwork.networkId;
const datahavenImageTag = options.datahavenImageTag || "datahavenxyz/datahaven:local";
const containerName = `datahaven-${nodeId}-${networkId}`;
const containerName = `datahaven-${nodeId}-${launchedNetwork.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
};
if (await isValidatorRunning(nodeId, launchedNetwork.networkId)) {
logger.warn(`⚠️ Node ${nodeId} is already running`);
return;
}
logger.info(`🚀 Launching DataHaven validator node: ${nodeId}...`);
logger.debug(`Launching DataHaven validator node: ${nodeId}...`);
// Get port mapping for the node
const portMapping = getPortMappingForNode(nodeId, networkId);
const command: string[] = [
"docker",
const args = [
"run",
"-d",
"--name",
containerName,
"--network",
options.launchedNetwork.networkName,
...portMapping,
launchedNetwork.networkName,
"-p",
String(DEFAULT_SUBSTRATE_WS_PORT),
datahavenImageTag,
`--${nodeId}`,
...COMMON_LAUNCH_ARGS
];
logger.debug(await $`sh -c "${command.join(" ")}"`.text());
await $`docker ${args}`.quiet();
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(
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
};
logger.debug(`DataHaven validator ${nodeId} launched on port ${publicPort}`);
};
/**
* 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
* Get validator info by name from validator set JSON
* @param name - Validator name (e.g., "alice", "bob")
* @returns Validator info
*/
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`);
}
export const getValidator = (name: string) => {
const node = validatorSet.validators.find((v) => v.solochainAuthorityName === name.toLowerCase());
if (!node) throw new Error(`Validator ${name} not found`);
return node;
};
/**
* Adds a validator to the EigenLayer allowlist
* @param connectors - The connectors to use
* @param validator - The validator to add to the allowlist
*/
/** Adds a validator to the EigenLayer allowlist */
export const addValidatorToAllowlist = async (
connectors: any,
validator: ValidatorInfo,
deployments: any
) => {
logger.info(`Adding validator ${validator.publicKey} to allowlist...`);
validatorName: string,
options: { connectors: TestConnectors; deployments: Deployments }
): Promise<void> => {
logger.debug(`Adding validator ${validatorName} to allowlist...`);
const { connectors, deployments } = options;
const validator = getValidator(validatorName);
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}`),
account: getOwnerAccount(),
chain: null
});
await connectors.publicClient.waitForTransactionReceipt({ hash });
logger.info(`✅ Validator ${validator.publicKey} added to allowlist`);
logger.debug(`Validator ${validatorName} added to allowlist`);
};
/** Register an operator in EigenLayer and for operator sets */
export async function registerOperator(
validatorName: string,
options: { connectors: TestConnectors; deployments: Deployments }
): Promise<void> {
const { connectors, deployments } = options;
const validator = getValidator(validatorName);
const account = privateKeyToAccount(validator.privateKey as `0x${string}`);
// Register as EigenLayer operator
const operatorHash = await connectors.walletClient.writeContract({
address: deployments.DelegationManager as `0x${string}`,
abi: delegationManagerAbi,
functionName: "registerAsOperator",
args: ["0x0000000000000000000000000000000000000000", 0, ""],
account,
chain: null
});
const operatorReceipt = await connectors.publicClient.waitForTransactionReceipt({
hash: operatorHash
});
if (operatorReceipt.status !== "success") {
throw new Error(`EigenLayer operator registration failed: ${operatorReceipt.status}`);
}
// Register 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,
chain: null
});
const receipt = await connectors.publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
throw new Error(`Operator set registration failed: ${receipt.status}`);
}
logger.debug(`Registered ${validatorName} as operator (gas: ${receipt.gasUsed})`);
}