diff --git a/.github/workflows/task-e2e.yml b/.github/workflows/task-e2e.yml index 8cf9080c..9fac6cc9 100644 --- a/.github/workflows/task-e2e.yml +++ b/.github/workflows/task-e2e.yml @@ -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: diff --git a/contracts/src/interfaces/IRewardsRegistry.sol b/contracts/src/interfaces/IRewardsRegistry.sol index 3857db2c..07fee9e6 100644 --- a/contracts/src/interfaces/IRewardsRegistry.sol +++ b/contracts/src/interfaces/IRewardsRegistry.sol @@ -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 diff --git a/contracts/src/libraries/SortedMerkleProof.sol b/contracts/src/libraries/SortedMerkleProof.sol new file mode 100644 index 00000000..9b22b25f --- /dev/null +++ b/contracts/src/libraries/SortedMerkleProof.sol @@ -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) + } + } +} diff --git a/contracts/src/middleware/RewardsRegistry.sol b/contracts/src/middleware/RewardsRegistry.sol index 76d4c907..5a9e5bb7 100644 --- a/contracts/src/middleware/RewardsRegistry.sol +++ b/contracts/src/middleware/RewardsRegistry.sol @@ -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(); diff --git a/contracts/test/RewardsRegistry.t.sol b/contracts/test/RewardsRegistry.t.sol index 34b7e988..ef5921fd 100644 --- a/contracts/test/RewardsRegistry.t.sol +++ b/contracts/test/RewardsRegistry.t.sol @@ -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); diff --git a/contracts/test/ServiceManagerRewardsRegistry.t.sol b/contracts/test/ServiceManagerRewardsRegistry.t.sol index f5676345..65854208 100644 --- a/contracts/test/ServiceManagerRewardsRegistry.t.sol +++ b/contracts/test/ServiceManagerRewardsRegistry.t.sol @@ -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); diff --git a/operator/pallets/datahaven-native-transfer/src/lib.rs b/operator/pallets/datahaven-native-transfer/src/lib.rs index 3cead876..a60315b0 100644 --- a/operator/pallets/datahaven-native-transfer/src/lib.rs +++ b/operator/pallets/datahaven-native-transfer/src/lib.rs @@ -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; /// The Snowbridge outbound queue for sending messages to Ethereum diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index 4ba5e971..9bb2ccc1 100644 Binary files a/test/.papi/metadata/datahaven.scale and b/test/.papi/metadata/datahaven.scale differ diff --git a/test/bunfig.toml b/test/bunfig.toml index 217991be..e9fb35bb 100644 --- a/test/bunfig.toml +++ b/test/bunfig.toml @@ -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 diff --git a/test/cli/handlers/deploy/relayer.ts b/test/cli/handlers/deploy/relayer.ts index 26e2e4c9..74f5e2c3 100644 --- a/test/cli/handlers/deploy/relayer.ts +++ b/test/cli/handlers/deploy/relayer.ts @@ -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, diff --git a/test/contract-bindings/generated.ts b/test/contract-bindings/generated.ts index 6a78f722..50c57c14 100644 --- a/test/contract-bindings/generated.ts +++ b/test/contract-bindings/generated.ts @@ -8355,7 +8355,7 @@ export const rewardsRegistryAbi = [ name: 'newRoot', internalType: 'bytes32', type: 'bytes32', - indexed: false, + indexed: true, }, { name: 'newRootIndex', diff --git a/test/docs/event-utilities-guide.md b/test/docs/event-utilities-guide.md deleted file mode 100644 index 2939c0ab..00000000 --- a/test/docs/event-utilities-guide.md +++ /dev/null @@ -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); -} -``` \ No newline at end of file diff --git a/test/framework/connectors.ts b/test/framework/connectors.ts index 1a20adf2..071c8f8b 100644 --- a/test/framework/connectors.ts +++ b/test/framework/connectors.ts @@ -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 { 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 { 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"); diff --git a/test/launcher/datahaven.ts b/test/launcher/datahaven.ts index f905d370..fcc004cc 100644 --- a/test/launcher/datahaven.ts +++ b/test/launcher/datahaven.ts @@ -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 { diff --git a/test/launcher/kurtosis.ts b/test/launcher/kurtosis.ts index 57bb8a5d..b7991e6b 100644 --- a/test/launcher/kurtosis.ts +++ b/test/launcher/kurtosis.ts @@ -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}`; diff --git a/test/launcher/network/index.ts b/test/launcher/network/index.ts index 98d54e48..55c1139f 100644 --- a/test/launcher/network/index.ts +++ b/test/launcher/network/index.ts @@ -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 }; diff --git a/test/launcher/relayers.ts b/test/launcher/relayers.ts index 23026c4c..7e572277 100644 --- a/test/launcher/relayers.ts +++ b/test/launcher/relayers.ts @@ -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"]) ]; diff --git a/test/launcher/types/index.ts b/test/launcher/types/index.ts index 14e62aa0..39962585 100644 --- a/test/launcher/types/index.ts +++ b/test/launcher/types/index.ts @@ -23,6 +23,7 @@ export interface LaunchNetworkResult { launchedNetwork: LaunchedNetwork; dataHavenRpcUrl: string; ethereumRpcUrl: string; + ethereumWsUrl?: string; ethereumClEndpoint: string; cleanup: () => Promise; } diff --git a/test/launcher/types/launchedNetwork.ts b/test/launcher/types/launchedNetwork.ts index 1b9ad65f..2472d1cc 100644 --- a/test/launcher/types/launchedNetwork.ts +++ b/test/launcher/types/launchedNetwork.ts @@ -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. diff --git a/test/launcher/validators.ts b/test/launcher/validators.ts index 680f5a5a..a81a7bbc 100644 --- a/test/launcher/validators.ts +++ b/test/launcher/validators.ts @@ -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 { - 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 { - 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 { - 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 { - 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; -} diff --git a/test/scripts/send-txn.ts b/test/scripts/send-txn.ts index 1f16aba0..a51bd1ec 100644 --- a/test/scripts/send-txn.ts +++ b/test/scripts/send-txn.ts @@ -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"; diff --git a/test/scripts/set-datahaven-parameters.ts b/test/scripts/set-datahaven-parameters.ts index 716962b8..13b127d5 100644 --- a/test/scripts/set-datahaven-parameters.ts +++ b/test/scripts/set-datahaven-parameters.ts @@ -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"; diff --git a/test/scripts/setup-validators.ts b/test/scripts/setup-validators.ts index 98d8211b..4edbbb93 100644 --- a/test/scripts/setup-validators.ts +++ b/test/scripts/setup-validators.ts @@ -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 = { diff --git a/test/suites/native-token-transfer.test.ts b/test/suites/native-token-transfer.test.ts index e8b0017a..2a0e68e4 100644 --- a/test/suites/native-token-transfer.test.ts +++ b/test/suites/native-token-transfer.test.ts @@ -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, + opts: { dhAccount?: string; ethAccount?: `0x${string}`; erc20Address?: `0x${string}` } +): Promise { + 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, - connectors.publicClient.readContract({ - address: deployedERC20Address, - abi: ERC20_ABI, - functionName: "symbol" - }) as Promise, - connectors.publicClient.readContract({ - address: deployedERC20Address, - abi: ERC20_ABI, - functionName: "decimals" - }) as Promise - ]); - - 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 + ]); + 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 ]); - 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, - connectors.publicClient.readContract({ - address: erc20Address, - abi: ERC20_ABI, - functionName: "totalSupply" - }) as Promise - ]); - 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, - connectors.publicClient.readContract({ - address: erc20Address, - abi: ERC20_ABI, - functionName: "totalSupply" - }) as Promise - ]); - - 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) }); diff --git a/test/suites/rewards-message.test.ts b/test/suites/rewards-message.test.ts index 5180c4b5..a39c71ee 100644 --- a/test/suites/rewards-message.test.ts +++ b/test/suites/rewards-message.test.ts @@ -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; -// 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
, + publicClient.readContract({ + address: rewardsRegistry.address, + abi: rewardsRegistry.abi, + functionName: "avs", + args: [] + }) as Promise
]); + 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
, - publicClient.readContract({ - address: rewardsRegistry.address, - abi: rewardsRegistry.abi, - functionName: "avs", - args: [] - }) as Promise
- ]); - - 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({ + 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); }); }); diff --git a/test/suites/validator-set-update.test.ts b/test/suites/validator-set-update.test.ts index f2ca6267..0c96daf5 100644 --- a/test/suites/validator-set-update.test.ts +++ b/test/suites/validator-set-update.test.ts @@ -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 { - 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 + ); }); diff --git a/test/utils/constants.ts b/test/utils/constants.ts index 1979b7cb..99b91566 100644 --- a/test/utils/constants.ts +++ b/test/utils/constants.ts @@ -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: 1–3 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 → 0–48s + * - 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: 1–2 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 → 0–30s + * - 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", diff --git a/test/utils/events.ts b/test/utils/events.ts index 6e2a9206..729fec8c 100644 --- a/test/utils/events.ts +++ b/test/utils/events.ts @@ -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 { - /** 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 { - /** 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( options: WaitForDataHavenEventOptions -): Promise> { - const { - api, - pallet, - event, - filter, - timeout: timeoutMs = 30000, - onEvent, - failOnTimeout - } = options; +): Promise { + 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 { - /** 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( options: WaitForEthereumEventOptions -): Promise { - const { client, address, abi, eventName, args, timeout = 30000, fromBlock, onEvent } = options; +): Promise { + const { client, address, abi, eventName, args, timeout = 30000, fromBlock } = options; - const log = await new Promise((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((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).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((_, 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(); + } } diff --git a/test/utils/papi.ts b/test/utils/papi.ts index 717c88e2..42a4dc63 100644 --- a/test/utils/papi.ts +++ b/test/utils/papi.ts @@ -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"; diff --git a/test/utils/rewards-helpers.ts b/test/utils/rewards-helpers.ts deleted file mode 100644 index d37c7870..00000000 --- a/test/utils/rewards-helpers.ts +++ /dev/null @@ -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 { - // 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 { - 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; -} - -export async function getEraRewardPoints( - dhApi: DataHavenApi, - eraIndex: number -): Promise { - 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(); - 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> { - // 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 { - 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 - }; -} diff --git a/test/utils/storage.ts b/test/utils/storage.ts deleted file mode 100644 index 0c165ddb..00000000 --- a/test/utils/storage.ts +++ /dev/null @@ -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 { - /** 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 { - /** 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( - options: WaitForDataHavenStorageOptions -): Promise> { - 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( - options: WaitForDataHavenStorageOptions & { - /** Items that should be contained in the storage value */ - contains: T[]; - } -): Promise> { - 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; - } - }); -} diff --git a/test/utils/validators.ts b/test/utils/validators.ts index ff839a66..e942455d 100644 --- a/test/utils/validators.ts +++ b/test/utils/validators.ts @@ -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 => { - 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 => { + name: string, + options: { launchedNetwork: LaunchedNetwork; datahavenImageTag?: string } +): Promise => { + 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; - 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 => { + 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 { + 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})`); +}