From 41788d56bb06a19ed290f701a2651f145a3e6d97 Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:31:40 +0100 Subject: [PATCH] test: refactor e2e tests (#365) This PR significantly refactors and improves the end-to-end testing framework and infrastructure. The primary focus was on simplifying the test suites, improving reliability through better resource management, and hardening the relayer infrastructure. All E2E tests are now passing on the CI and demonstrate consistent reliability when run locally. ### Key Changes #### 1. E2E Test Suite Refactor & Cleanup * **Simplified Test Logic**: Heavily refactored the core test suites (`native-token-transfer.test.ts`, `rewards-message.test.ts`, and `validator-set-update.test.ts`). The new implementation is much cleaner, utilizing shared helpers to reduce boilerplate. * **Utility Consolidation**: Removed redundant utility files (`storage.ts`, `rewards-helpers.ts`) and simplified `events.ts`. Event waiting now uses `rxjs` for Substrate and native `viem` watchers for Ethereum, which is more robust and easier to maintain. * **Better Connector Management**: Unified the creation and cleanup of test clients in `ConnectorFactory`. It now handles the lifecycle of WebSocket connections more gracefully, including clearing the `socketClientCache` to prevent reconnection noise during teardown. #### 2. Infrastructure & Stability * **Relayer Relaunch Policy**: Added a restart policy for Snowbridge relayer containers. They are now configured with `--restart on-failure:5`, ensuring that relayers automatically relaunch if they crash during the sensitive initialization phase. * **WebSocket Integration**: * Updated the `ConnectorFactory` to prefer **WebSockets** for the Ethereum public client, which is essential for efficient, event-heavy E2E testing. * Enhanced `launchKurtosisNetwork` to correctly identify and register the Execution Layer's WebSocket endpoint from Kurtosis. * **Disabled Contract Injection**: This PR temporarily disables the automatic injection of contracts into the genesis state by default. * *Reason*: I encountered issues generating a valid `state-diff.json` for the latest contract versions. Even after applying several workarounds, the injected state remained unstable. As a result, I've reverted to manual contract deployment during the launch sequence for better reliability for now. #### 3. Documentation & Maintenance * Removed obsolete documentation (`event-utilities-guide.md`) that no longer reflects the simplified event-handling API. * Cleaned up `test/launcher/validators.ts` and moved logic into more appropriate helpers. --------- Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com> --- .github/workflows/task-e2e.yml | 2 +- contracts/src/interfaces/IRewardsRegistry.sol | 2 +- contracts/src/libraries/SortedMerkleProof.sol | 50 + contracts/src/middleware/RewardsRegistry.sol | 14 +- contracts/test/RewardsRegistry.t.sol | 16 +- .../test/ServiceManagerRewardsRegistry.t.sol | 28 +- .../datahaven-native-transfer/src/lib.rs | 1 + test/.papi/metadata/datahaven.scale | Bin 628936 -> 629152 bytes test/bunfig.toml | 5 +- test/cli/handlers/deploy/relayer.ts | 2 +- test/contract-bindings/generated.ts | 2 +- test/docs/event-utilities-guide.md | 298 ------ test/framework/connectors.ts | 51 +- test/launcher/datahaven.ts | 2 +- test/launcher/kurtosis.ts | 15 + test/launcher/network/index.ts | 3 + test/launcher/relayers.ts | 5 +- test/launcher/types/index.ts | 1 + test/launcher/types/launchedNetwork.ts | 19 + test/launcher/validators.ts | 202 +--- test/scripts/send-txn.ts | 2 +- test/scripts/set-datahaven-parameters.ts | 2 +- test/scripts/setup-validators.ts | 20 +- test/suites/native-token-transfer.test.ts | 868 +++++++----------- test/suites/rewards-message.test.ts | 674 ++++---------- test/suites/validator-set-update.test.ts | 396 +++----- test/utils/constants.ts | 36 + test/utils/events.ts | 283 ++---- test/utils/papi.ts | 2 +- test/utils/rewards-helpers.ts | 228 ----- test/utils/storage.ts | 165 ---- test/utils/validators.ts | 275 ++---- 32 files changed, 1047 insertions(+), 2622 deletions(-) create mode 100644 contracts/src/libraries/SortedMerkleProof.sol delete mode 100644 test/docs/event-utilities-guide.md delete mode 100644 test/utils/rewards-helpers.ts delete mode 100644 test/utils/storage.ts 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 4ba5e9716983fbc70b3dc41483f58f90d6645a6d..9bb2ccc1d5724b16fb3d2ac00a6196c6bbfe9861 100644 GIT binary patch delta 239 zcmX@HQEkCywT2eP7N!>F7M2#)7Pc1l7LF~P4en+v39cm>sYR)!xxx8mKsqx$&oMbU zzcjCeAt0EcvUcCntqv7SHt%mTV2nCa75ah!>VR^j@z{2~RIZiS?x%#`$0 zg`(8r)S|M~;s-!wnZ*jl8Tq9-DGEuc3Mr{YnPsUd3TZ|8xe7ol5ju17lM_oa^Yau+ zi!<}mUnnFhz(jOQ@^up-u2D$N&jY%zB(+FEBULY5Pe;K+FtIozHN_>hIJqdZ0AzsX K_C|M3_do!#xLPUz delta 35 qcmZ3mS?$C|wT2eP7N!>F7M2#)7Pc1l7LF~P4epE#+ao 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})`); +}