mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-23 09:18:21 +00:00
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:
parent
5121ae002b
commit
3acbc06c74
8 changed files with 689 additions and 44 deletions
110
CLAUDE.md
110
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
|
||||
### 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
|
||||
|
|
|
|||
3
contracts/.gitignore
vendored
3
contracts/.gitignore
vendored
|
|
@ -12,3 +12,6 @@ docs/
|
|||
|
||||
# Dotenv file
|
||||
.env
|
||||
|
||||
# Local CLAUDE configuration
|
||||
CLAUDE.local.md
|
||||
|
|
|
|||
5
operator/.gitignore
vendored
5
operator/.gitignore
vendored
|
|
@ -28,4 +28,7 @@ tools/build
|
|||
.env
|
||||
|
||||
# Temporary files
|
||||
**/tmp
|
||||
**/tmp
|
||||
|
||||
# Local CLAUDE configuration
|
||||
CLAUDE.local.md
|
||||
5
test/.gitignore
vendored
5
test/.gitignore
vendored
|
|
@ -35,4 +35,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
|
||||
|
||||
# Test files
|
||||
tmp/*
|
||||
tmp/*
|
||||
|
||||
# Local CLAUDE configuration
|
||||
CLAUDE.local.md
|
||||
|
|
@ -74,4 +74,4 @@
|
|||
"ssh2",
|
||||
"utf-8-validate"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
596
test/suites/native-token-transfer.test.ts
Normal file
596
test/suites/native-token-transfer.test.ts
Normal 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
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue