diff --git a/CLAUDE.md b/CLAUDE.md index 0ceb2748..e4776637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,15 @@ DataHaven is an EVM-compatible Substrate blockchain secured by EigenLayer. It br - Frontier pallets for EVM compatibility - External validators with rewards system +## Pre-requisites + +- [Kurtosis](https://docs.kurtosis.com/install): For launching test networks +- [Bun](https://bun.sh/) v1.2+: TypeScript runtime and package manager +- [Docker](https://www.docker.com/): For container management +- [Foundry](https://getfoundry.sh/): For smart contract compilation/deployment +- [Helm](https://helm.sh/): Kubernetes package manager +- [Zig](https://ziglang.org/) (macOS only): For crossbuilding the node + ## Critical Development Commands ### E2E Testing Environment (from `/test` directory) @@ -20,68 +29,103 @@ bun i # Install dependencies bun cli # Interactive CLI for test environment # Code Quality -bun fmt:fix # Fix TypeScript formatting +bun fmt:fix # Fix TypeScript formatting (Biome) bun typecheck # TypeScript type checking -# Code Generation (run after contract changes) +# Code Generation (run after contract/runtime changes) bun generate:wagmi # Generate TypeScript contract bindings bun generate:types # Generate Polkadot-API types from runtime +bun generate:types:fast # Generate types with fast-runtime feature -# Local Development -bun build:docker:operator # Build local DataHaven Docker image -bun start:e2e:local # Launch local test network -bun stop:e2e # Stop all test services +# Local Development - Quick Start +bun cli launch # Interactive launcher (recommended) +bun start:e2e:local # Launch full local test network +bun start:e2e:verified # Launch with Blockscout + contract verification +bun start:e2e:ci # CI-optimized network launch + +# Stopping Services +bun stop:e2e # Stop all test services (interactive) +bun stop:dh # Stop DataHaven only +bun stop:sb # Stop Snowbridge relayers only +bun stop:eth # Stop Ethereum network only # Testing -bun test:e2e # Run E2E test suite +bun test:e2e # Run all E2E test suites +bun test:e2e:parallel # Run tests with limited ``` ### Rust/Operator Development ```bash cd operator -cargo build --release --features fast-runtime # Development build -cargo test # Run tests +cargo build --release --features fast-runtime # Development build (faster) +cargo build --release # Production build +cargo test # Run all tests +cargo fmt # Format Rust code +cargo clippy # Lint Rust code ``` ### Smart Contracts (from `/contracts` directory) ```bash +forge clean # Clean build artifacts forge build # Build contracts forge test # Run tests +forge test -vvv # Run tests with stack traces forge fmt # Format Solidity code ``` ## Architecture Essentials -### Cross-Component Dependencies -- **Contracts → Operator**: DataHaven AVS contracts register operators and manage slashing -- **Operator → Contracts**: Operator reads validator registry from contracts -- **Test → Both**: E2E tests deploy contracts and run operator nodes -- **Snowbridge**: Enables native token transfers and message passing between chains +### Repository Structure +``` +datahaven/ +├── contracts/ # EigenLayer AVS smart contracts +├── operator/ # Substrate-based DataHaven node +│ ├── node/ # Node implementation +│ ├── pallets/ # Custom pallets (validators, rewards, transfers) +│ └── runtime/ # Runtime configurations (mainnet/stagenet/testnet) +├── test/ # E2E testing framework +│ ├── suites/ # Test scenarios +│ ├── framework/ # Test utilities +│ └── launcher/ # Network deployment tools +└── deploy/ # Kubernetes deployment charts +``` -### Key Design Patterns -1. **Service Manager Pattern**: Contracts use EigenLayer's service manager for operator coordination -2. **Rewards Registry**: Tracks validator performance and distributes rewards -3. **Slashing Mechanisms**: Enforces protocol rules through economic penalties -4. **Runtime Upgrades**: Substrate's forkless upgrade system for protocol evolution +### Cross-Component Dependencies +- **Contracts → Operator**: AVS contracts register/slash operators via DataHavenServiceManager +- **Operator → Contracts**: Reads validator registry, submits performance data +- **Test → Both**: Deploys contracts, launches nodes, runs cross-chain scenarios +- **Snowbridge**: Bidirectional bridge for tokens/messages between Ethereum↔DataHaven + +### Key Components +1. **DataHavenServiceManager**: Core AVS contract managing operator lifecycle +2. **RewardsRegistry**: Tracks validator performance and reward distribution +3. **VetoableSlasher**: Implements slashing with veto period for dispute resolution +4. **External Validators Pallet**: Manages validator set on Substrate side +5. **Native Transfer Pallet**: Handles cross-chain token transfers via Snowbridge ### Testing Strategy -- **Unit Tests**: In each component directory -- **Integration Tests**: E2E tests in `/test` that spin up full networks -- **Kurtosis**: Manages complex multi-container test environments -- **Contract Verification**: Automated on Blockscout in test networks +- **Unit Tests**: Component-specific (`cargo test`, `forge test`) +- **Integration Tests**: Full network scenarios in `/test/suites` +- **Kurtosis**: Orchestrates multi-container test environments +- **Parallel Testing**: Use `test:e2e:parallel` for faster CI runs ### Development Workflow 1. Make changes to relevant component -2. Run component-specific tests -3. If changing contracts, regenerate TypeScript bindings -4. Build Docker image if testing operator changes -5. Run E2E tests to verify cross-component interactions +2. Run component-specific tests and linters +3. Regenerate bindings if contracts/runtime changed: + - `bun generate:wagmi` for contract changes + - `bun generate:types` for runtime changes +4. Build Docker image for operator changes: `bun build:docker:operator` +5. Run E2E tests to verify integration: `bun test:e2e` +6. Use `bun cli launch --verified --blockscout` for manual testing -### Common Pitfalls -- Always regenerate types after runtime changes (`bun generate:types`) -- E2E tests require Kurtosis engine running -- Contract changes require regenerating Wagmi bindings -- Snowbridge relayers need proper configuration for cross-chain tests -- Use `fast-runtime` feature for quicker development cycles \ No newline at end of file +### Common Pitfalls & Solutions +- **Types mismatch**: Regenerate with `bun generate:types` after runtime changes +- **Kurtosis not running**: Ensure Docker is running and Kurtosis engine is started +- **Contract changes not reflected**: Run `bun generate:wagmi` after modifications +- **Forge build errors**: Try `forge clean` then rebuild +- **Slow development cycle**: Use `--features fast-runtime` for faster block times +- **Network launch halts**: Check Blockscout - forge output can appear frozen +- **macOS crossbuild fails**: Ensure Zig is installed for cross-compilation diff --git a/contracts/.gitignore b/contracts/.gitignore index 69d77dda..58010582 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -12,3 +12,6 @@ docs/ # Dotenv file .env + +# Local CLAUDE configuration +CLAUDE.local.md diff --git a/operator/.gitignore b/operator/.gitignore index 795942fe..53505300 100644 --- a/operator/.gitignore +++ b/operator/.gitignore @@ -28,4 +28,7 @@ tools/build .env # Temporary files -**/tmp \ No newline at end of file +**/tmp + +# Local CLAUDE configuration +CLAUDE.local.md \ No newline at end of file diff --git a/test/.gitignore b/test/.gitignore index 44372ad6..cebda1e9 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -35,4 +35,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Test files -tmp/* \ No newline at end of file +tmp/* + +# Local CLAUDE configuration +CLAUDE.local.md \ No newline at end of file diff --git a/test/package.json b/test/package.json index 939ed570..a8d3567f 100644 --- a/test/package.json +++ b/test/package.json @@ -74,4 +74,4 @@ "ssh2", "utf-8-validate" ] -} +} \ No newline at end of file diff --git a/test/suites/cross-chain.test.ts b/test/suites/cross-chain.test.ts index 565fed02..65e782bb 100644 --- a/test/suites/cross-chain.test.ts +++ b/test/suites/cross-chain.test.ts @@ -13,9 +13,8 @@ class CrossChainTestSuite extends BaseTestSuite { } override async onSetup(): Promise { - // Wait a bit for relayers to fully initialize - logger.info("Waiting for relayers to initialize..."); - await Bun.sleep(10000); // 10 seconds + // Relayers initialization is handled by the network setup + logger.info("Cross-chain test setup complete"); } } diff --git a/test/suites/native-token-transfer.test.ts b/test/suites/native-token-transfer.test.ts new file mode 100644 index 00000000..f7bd1d15 --- /dev/null +++ b/test/suites/native-token-transfer.test.ts @@ -0,0 +1,596 @@ +/** + * Native Token Transfer E2E Tests + * + * Tests the native HAVE token transfer functionality between DataHaven and Ethereum + * using the Snowbridge cross-chain messaging protocol. + * + * Prerequisites: + * - DataHaven network with DataHavenNativeTransfer pallet + * - Ethereum network with Gateway contract + * - Snowbridge relayers running + * - Sudo access for token registration + */ + +import { beforeAll, describe, expect, it } from "bun:test"; +import { Binary } from "@polkadot-api/substrate-bindings"; +import { FixedSizeBinary } from "polkadot-api"; +import { + ANVIL_FUNDED_ACCOUNTS, + getPapiSigner, + logger, + parseDeploymentsFile, + SUBSTRATE_FUNDED_ACCOUNTS, + waitForDataHavenEvent, + waitForEthereumEvent +} from "utils"; +import { decodeEventLog, encodeAbiParameters, parseEther } from "viem"; +import { gatewayAbi } from "../contract-bindings"; +import { BaseTestSuite } from "../framework"; + +// Constants +// The actual Ethereum sovereign account used by the runtime (derived from runtime configuration) +const ETHEREUM_SOVEREIGN_ACCOUNT = "0xd8030FB68Aa5B447caec066f3C0BdE23E6db0a05"; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +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"); + + // 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}`; + + const tokenAddress = (await connectors.publicClient.readContract({ + address: deployments.Gateway, + abi: gatewayAbi, + functionName: "tokenAddressOf", + args: [tokenId] + })) as `0x${string}`; + + return tokenAddress === ZERO_ADDRESS ? null : tokenAddress; +} + +class NativeTokenTransferTestSuite extends BaseTestSuite { + constructor() { + super({ + suiteName: "native-token-transfer", + networkOptions: { + slotTime: 2 + } + }); + + this.setupHooks(); + } +} + +// Create the test suite instance +const suite = new NativeTokenTransferTestSuite(); + +// Create shared signer instance to maintain nonce tracking across tests +let alithSigner: ReturnType; + +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(); + + // 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("HAVE"), + symbol: Binary.fromText("wHAVE"), + decimals: 18 + } + }); + + // Create and sign the transaction + const sudoTx = connectors.dhApi.tx.Sudo.sudo({ + call: registerTx.decodedCall + }); + + // 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({ + client: connectors.publicClient, + address: deployments.Gateway, + abi: gatewayAbi, + eventName: "ForeignTokenRegistered", + timeout: 300_000 // set appropriately + }), + // Submit and wait for transaction on DataHaven + sudoTx.signAndSubmit(alithSigner) + ]); + + // Verify DataHaven transaction succeeded + expect(dhTxResult.ok).toBe(true); + + // Verify the Ethereum event was received + expect(ethEventResult.log).not.toBeNull(); + + // Check for events in the DataHaven transaction result + const { events } = dhTxResult; + + const sudoEvent = events.find((e: any) => e.type === "Sudo" && e.value.type === "Sudid"); + expect(sudoEvent).toBeDefined(); + + // Find SnowbridgeSystemV2.RegisterToken event + const registerTokenEvent = events.find( + (e: any) => e.type === "SnowbridgeSystemV2" && e.value.type === "RegisterToken" + ); + expect(registerTokenEvent).toBeDefined(); + + const tokenIdRaw = registerTokenEvent?.value?.value?.foreign_token_id; + expect(tokenIdRaw).toBeDefined(); + const tokenId = tokenIdRaw.asHex(); + + const eventArgs = (ethEventResult.log as any)?.args; + expect(eventArgs?.tokenID).toBe(tokenId); + + // Get the deployed token address from the event + const deployedERC20Address = eventArgs?.token as `0x${string}`; + expect(deployedERC20Address).not.toBe(ZERO_ADDRESS); + + logger.debug(`ERC20 token deployed at: ${deployedERC20Address}`); + + 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("HAVE"); + expect(tokenSymbol).toBe("wHAVE"); + 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"); + const tx = connectors.dhApi.tx.DataHavenNativeTransfer.transfer_to_ethereum({ + recipient: FixedSizeBinary.fromHex(ethereumSender) as FixedSizeBinary<20>, + amount: mintAmount, + fee + }); + + // Start watcher first and submit in parallel; look back one block for safety + 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 + }), + tx.signAndSubmit(alithSigner) + ]); + + 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 +}); diff --git a/test/utils/events.ts b/test/utils/events.ts index 600f8d10..8fdfbbfb 100644 --- a/test/utils/events.ts +++ b/test/utils/events.ts @@ -142,9 +142,7 @@ export async function waitForEthereumEvent( if (unwatch) { unwatch(); } - if (timeoutId) { - clearTimeout(timeoutId); - } + if (timeoutId) clearTimeout(timeoutId); }; // Set up timeout @@ -163,8 +161,6 @@ export async function waitForEthereumEvent( args, fromBlock, onLogs: (logs) => { - logger.debug(`Ethereum event ${eventName} received: ${logs.length} logs`); - if (logs.length > 0) { matchedLog = logs[0]; if (onEvent) { @@ -175,6 +171,7 @@ export async function waitForEthereumEvent( } }, 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);