test: Native token transfer e2e tests (#120)

### Summary
- **Add** `test/suites/native-token-transfer.test.ts` focused on the
HAVE native token lifecycle via Snowbridge v2.
- **Validate** registration, DataHaven → Ethereum mints, Ethereum →
DataHaven unlocks, event emission, and 1:1 backing invariant.

### Tests added
- should register DataHaven native token on Ethereum
- should transfer tokens from DataHaven to Ethereum
- should maintain 1:1 backing ratio
- should emit transfer events
- should transfer tokens from Ethereum to DataHaven (Snowbridge v2)

### What the suite covers
- **Registration**: Sudo-registers the native token; confirms
`ForeignTokenRegistered` on the Gateway; verifies ERC-20 metadata
(`HAVE`/`wHAVE`, 18 decimals).
- **DataHaven → Ethereum**: Executes `transfer_to_ethereum`; asserts
Substrate events (`TokensLocked`, `TokensTransferredToEthereum`);
observes Ethereum `Transfer` mint (from zero address); validates sender
balance delta, sovereign account increase, and ERC-20 recipient credit.
- **Backing invariant**: Ensures sovereign account balance ≥ ERC-20
total supply.
- **Event emission**: Confirms key Substrate events without polling
delays.
- **Ethereum → DataHaven**: Approves and calls `Gateway.sendToken`; if
unsupported locally, the test skips; otherwise asserts burn on Ethereum
and unlock on DataHaven with corresponding balance deltas.

---------

Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com>
This commit is contained in:
Ahmad Kaouk 2025-08-22 18:27:14 +02:00 committed by GitHub
parent 5121ae002b
commit 3acbc06c74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 689 additions and 44 deletions

110
CLAUDE.md
View file

@ -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
### 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

View file

@ -12,3 +12,6 @@ docs/
# Dotenv file
.env
# Local CLAUDE configuration
CLAUDE.local.md

5
operator/.gitignore vendored
View file

@ -28,4 +28,7 @@ tools/build
.env
# Temporary files
**/tmp
**/tmp
# Local CLAUDE configuration
CLAUDE.local.md

5
test/.gitignore vendored
View file

@ -35,4 +35,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Test files
tmp/*
tmp/*
# Local CLAUDE configuration
CLAUDE.local.md

View file

@ -74,4 +74,4 @@
"ssh2",
"utf-8-validate"
]
}
}

View file

@ -13,9 +13,8 @@ class CrossChainTestSuite extends BaseTestSuite {
}
override async onSetup(): Promise<void> {
// 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");
}
}

View file

@ -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<typeof getPapiSigner>;
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<string>,
connectors.publicClient.readContract({
address: deployedERC20Address,
abi: ERC20_ABI,
functionName: "symbol"
}) as Promise<string>,
connectors.publicClient.readContract({
address: deployedERC20Address,
abi: ERC20_ABI,
functionName: "decimals"
}) as Promise<number>
]);
expect(tokenName).toBe("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<bigint>,
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "totalSupply"
}) as Promise<bigint>
]);
expect(initialEthTokenBalance).toBeGreaterThanOrEqual(amount);
const initialDhRecipientBalance =
await connectors.dhApi.query.System.Account.getValue(dhRecipient);
const initialSovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
// Approve Gateway to pull tokens
const approveHash = await ethWalletClient.writeContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "approve",
args: [deployments.Gateway as `0x${string}`, amount],
chain: null
});
const approveReceipt = await connectors.publicClient.waitForTransactionReceipt({
hash: approveHash
});
expect(approveReceipt.status).toBe("success");
// Build Snowbridge v2 send payload
const assets = [
encodeAbiParameters(
[
{ name: "kind", type: "uint8" },
{ name: "token", type: "address" },
{ name: "value", type: "uint128" }
],
[0, erc20Address, amount]
)
];
// The claimer should be the recipient on DataHaven (dhRecipient)
// This tells the system who should receive the unlocked tokens
const claimer = dhRecipient as `0x${string}`;
logger.info(`🔑 Setting claimer to: ${claimer} (matches dhRecipient: ${dhRecipient})`);
// For now, we can use an empty XCM since the claimer field specifies the recipient
// The Snowbridge system will handle the token unlock to the claimer address
const xcm = "0x" as `0x${string}`;
// Start DH event watcher BEFORE sending Ethereum tx to avoid missing the event
logger.debug("Starting TokensUnlocked watcher on DataHaven before sending Ethereum tx...");
const dhEventPromise = waitForDataHavenEvent<{
account: any;
amount: any;
}>({
api: connectors.dhApi,
pallet: "DataHavenNativeTransfer",
event: "TokensUnlocked",
filter: (e: any) => {
const acct =
typeof e?.account === "string"
? e.account
: (e?.account?.asHex?.() ?? e?.account?.toString?.());
const amt = typeof e?.amount === "bigint" ? e.amount : BigInt(e?.amount ?? 0);
const isMatch = acct?.toLowerCase?.() === dhRecipient.toLowerCase() && amt === amount;
if (isMatch) {
logger.debug(`Matched TokensUnlocked: account=${acct}, amount=${amt}`);
}
return Boolean(isMatch);
},
timeout: 600_000
});
// Send v2_sendMessage and assert hash before awaiting all
logger.info(
`🚀 Submitting Ethereum transaction: ${amount} tokens to DataHaven recipient ${dhRecipient}`
);
const sendHash = await ethWalletClient.writeContract({
address: deployments.Gateway as `0x${string}`,
abi: gatewayAbi,
functionName: "v2_sendMessage",
args: [xcm, assets as any, claimer, executionFee, relayerFee],
value: executionFee + relayerFee,
chain: null
});
expect(sendHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
// Await both Ethereum receipt and DH TokensUnlocked event together
const [sendReceipt, dhEvent] = await Promise.all([
connectors.publicClient.waitForTransactionReceipt({ hash: sendHash }),
dhEventPromise
]);
expect(sendReceipt.status).toBe("success");
// Assert OutboundMessageAccepted from receipt logs
const hasOutboundAccepted = (sendReceipt.logs ?? []).some((log: any) => {
try {
const decoded = decodeEventLog({ abi: gatewayAbi, data: log.data, topics: log.topics });
return decoded.eventName === "OutboundMessageAccepted";
} catch {
return false;
}
});
expect(hasOutboundAccepted).toBe(true);
// Event must exist (filter already matched account and amount)
expect(dhEvent?.data).toBeDefined();
// Final balances
const [finalEthTokenBalance, finalTotalSupply] = await Promise.all([
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [ethereumSender]
}) as Promise<bigint>,
connectors.publicClient.readContract({
address: erc20Address,
abi: ERC20_ABI,
functionName: "totalSupply"
}) as Promise<bigint>
]);
const finalDhRecipientBalance =
await connectors.dhApi.query.System.Account.getValue(dhRecipient);
const finalSovereignBalance = await connectors.dhApi.query.System.Account.getValue(
ETHEREUM_SOVEREIGN_ACCOUNT
);
// Assertions: burn on Ethereum and unlock on DataHaven
expect(finalEthTokenBalance).toBe(initialEthTokenBalance - amount);
expect(finalTotalSupply).toBe(initialTotalSupply - amount);
const dhIncrease = finalDhRecipientBalance.data.free - initialDhRecipientBalance.data.free;
const sovereignDecrease = initialSovereignBalance.data.free - finalSovereignBalance.data.free;
expect(dhIncrease).toBe(amount);
expect(sovereignDecrease).toBe(amount);
}, 900_000); // 15 minute timeout for cross-chain transfers
});

View file

@ -142,9 +142,7 @@ export async function waitForEthereumEvent<TAbi extends Abi = Abi>(
if (unwatch) {
unwatch();
}
if (timeoutId) {
clearTimeout(timeoutId);
}
if (timeoutId) clearTimeout(timeoutId);
};
// Set up timeout
@ -163,8 +161,6 @@ export async function waitForEthereumEvent<TAbi extends Abi = Abi>(
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<TAbi extends Abi = Abi>(
}
},
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);