datahaven/test/e2e/suites/native-token-transfer.test.ts
Steve Degosserie 17c215d047
refactor(test): reorganize e2e test suites (#373)
## Summary

Reorganizes the test directory structure for better clarity and
maintainability:

- **Rename `test/datahaven/` → `test/moonwall/`**: Clearly identifies
these as Moonwall single-node tests
- **Move `test/framework/` → `test/e2e/framework/`**: Groups e2e test
utilities under a dedicated folder
- **Move `test/suites/` → `test/e2e/suites/`**: Groups e2e test suites
with the framework
- **Add `test/e2e/framework/validators.ts`**: Extracts validator test
helpers from utils into the e2e framework
- **Update documentation**: README.md and E2E_FRAMEWORK_OVERVIEW.md
reflect the new structure

### New Directory Structure

```
test/
├── e2e/
│   ├── suites/          # E2E test suites (Kurtosis-based)
│   └── framework/       # E2E test utilities & helpers
├── moonwall/            # Moonwall single-node tests
├── launcher/            # Network deployment tools
└── ...
```

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:52:33 +02:00

438 lines
15 KiB
TypeScript

/**
* 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,
CROSS_CHAIN_TIMEOUTS,
getPapiSigner,
logger,
parseDeploymentsFile,
SUBSTRATE_FUNDED_ACCOUNTS,
waitForDataHavenEvent,
waitForEthereumEvent,
ZERO_ADDRESS
} from "utils";
import { decodeEventLog, encodeAbiParameters, erc20Abi, parseEther, parseEventLogs } from "viem";
import { gatewayAbi } from "../../contract-bindings";
import { BaseTestSuite } from "../framework";
import type { TestConnectors } from "../framework/connectors";
// Dynamic values fetched from runtime
let ethereumSovereignAccount: string;
let nativeTokenId: `0x${string}` | null = null;
interface BalanceSnapshot {
dh: bigint;
sovereign: bigint;
erc20: bigint;
}
async function getBalanceSnapshot(
connectors: Pick<TestConnectors, "dhApi" | "publicClient">,
opts: { dhAccount?: string; ethAccount?: `0x${string}`; erc20Address?: `0x${string}` }
): Promise<BalanceSnapshot> {
const { dhApi, publicClient } = connectors;
const { dhAccount, ethAccount, erc20Address } = opts;
const [dhBalance, sovereignBalance, erc20Balance] = await Promise.all([
dhAccount ? dhApi.query.System.Account.getValue(dhAccount) : null,
dhApi.query.System.Account.getValue(ethereumSovereignAccount),
erc20Address && ethAccount
? publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "balanceOf",
args: [ethAccount]
})
: 0n
]);
return {
dh: dhBalance?.data.free ?? 0n,
sovereign: sovereignBalance.data.free,
erc20: erc20Balance as bigint
};
}
function expectBalanceDeltas(
before: BalanceSnapshot,
after: BalanceSnapshot,
expected: { dhMin?: bigint; dhExact?: bigint; sovereign?: bigint; erc20?: bigint }
): void {
if (expected.dhMin !== undefined) {
const decrease = before.dh - after.dh;
expect(decrease).toBeGreaterThanOrEqual(expected.dhMin);
expect(decrease - expected.dhMin).toBeLessThan(parseEther("0.01")); // tx fee sanity check
}
if (expected.dhExact !== undefined) {
expect(after.dh - before.dh).toBe(expected.dhExact);
}
if (expected.sovereign !== undefined) {
expect(after.sovereign - before.sovereign).toBe(expected.sovereign);
}
if (expected.erc20 !== undefined) {
expect(after.erc20 - before.erc20).toBe(expected.erc20);
}
}
let deployments: any;
async function getNativeERC20Address(connectors: any): Promise<`0x${string}` | null> {
if (!deployments) throw new Error("Global deployments not initialized");
if (!nativeTokenId) return null;
// Query the ERC20 address from Gateway using the token ID from registration
const tokenAddress = (await connectors.publicClient.readContract({
address: deployments!.Gateway,
abi: gatewayAbi,
functionName: "tokenAddressOf",
args: [nativeTokenId]
})) as `0x${string}`;
return tokenAddress === ZERO_ADDRESS ? null : tokenAddress;
}
async function requireNativeERC20Address(connectors: any): Promise<`0x${string}`> {
const address = await getNativeERC20Address(connectors);
if (!address) {
throw new Error(
`Native ERC20 address not available (nativeTokenId=${nativeTokenId ?? "null"}). ` +
`Did the 'register DataHaven native token on Ethereum' test succeed?`
);
}
return address;
}
class NativeTokenTransferTestSuite extends BaseTestSuite {
constructor() {
super({ suiteName: "native-token-transfer" });
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();
const connectors = suite.getTestConnectors();
ethereumSovereignAccount = await (
connectors.dhApi.constants as any
).DataHavenNativeTransfer.EthereumSovereignAccount();
logger.debug(`Ethereum sovereign account: ${ethereumSovereignAccount}`);
});
it(
"should register DataHaven native token on Ethereum",
async () => {
const connectors = suite.getTestConnectors();
// Ensure token is not already deployed (nativeTokenId is null until registered)
expect(await getNativeERC20Address(connectors)).toBeNull();
const fromBlock = await connectors.publicClient.getBlockNumber();
// Build transaction to register token
const sudoTx = connectors.dhApi.tx.Sudo.sudo({
call: connectors.dhApi.tx.SnowbridgeSystemV2.register_token({
sender: {
type: "V5",
value: { parents: 0, interior: { type: "Here", value: undefined } }
},
asset_id: {
type: "V5",
value: { parents: 0, interior: { type: "Here", value: undefined } }
},
metadata: {
name: Binary.fromText("HAVE"),
symbol: Binary.fromText("wHAVE"),
decimals: 18
}
}).decodedCall
});
const dhTxResult = await sudoTx.signAndSubmit(alithSigner);
expect(dhTxResult.ok).toBe(true);
// Verify token IDs match across chains and store for subsequent tests
const registerEvent = dhTxResult.events.find(
(e: any) => e.type === "SnowbridgeSystemV2" && e.value?.type === "RegisterToken"
);
expect(registerEvent).toBeDefined();
nativeTokenId = registerEvent!.value.value.foreign_token_id.asHex();
logger.debug(`Native token ID: ${nativeTokenId}`);
// Wait for cross-chain confirmation after we have the token ID (and filter by it).
const ethEvent = await waitForEthereumEvent({
client: connectors.publicClient,
address: deployments!.Gateway,
abi: gatewayAbi,
eventName: "ForeignTokenRegistered",
args: { tokenID: nativeTokenId },
fromBlock: fromBlock > 0n ? fromBlock - 1n : fromBlock,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS
});
const { args: ethTokenEvent } = decodeEventLog({
abi: gatewayAbi,
eventName: "ForeignTokenRegistered",
data: ethEvent.data,
topics: ethEvent.topics
}) as { args: { tokenID: string; token: `0x${string}` } };
expect(ethTokenEvent.tokenID).toBe(nativeTokenId!);
// Verify ERC20 metadata
const deployedERC20 = ethTokenEvent.token;
logger.debug(`DataHaven native token deployed at: ${deployedERC20}`);
const [name, symbol, decimals] = await Promise.all([
connectors.publicClient.readContract({
address: deployedERC20,
abi: erc20Abi,
functionName: "name"
}),
connectors.publicClient.readContract({
address: deployedERC20,
abi: erc20Abi,
functionName: "symbol"
}),
connectors.publicClient.readContract({
address: deployedERC20,
abi: erc20Abi,
functionName: "decimals"
})
]);
expect(name).toBe("HAVE");
expect(symbol).toBe("wHAVE");
expect(decimals).toBe(18);
},
CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS + CROSS_CHAIN_TIMEOUTS.EVENT_CONFIRMATION_MS
);
it(
"should transfer tokens from DataHaven to Ethereum",
async () => {
const connectors = suite.getTestConnectors();
const erc20Address = await requireNativeERC20Address(connectors);
// Set up transfer parameters
const recipient = ANVIL_FUNDED_ACCOUNTS[0].publicKey;
const amount = parseEther("100");
const fee = parseEther("1");
// Capture initial balances
const before = await getBalanceSnapshot(connectors, {
dhAccount: SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey,
ethAccount: recipient,
erc20Address
});
// Build transfer transaction
const tx = connectors.dhApi.tx.DataHavenNativeTransfer.transfer_to_ethereum({
recipient: FixedSizeBinary.fromHex(recipient) as FixedSizeBinary<20>,
amount,
fee
});
// Submit transaction and wait for cross-chain confirmation
const startBlock = await connectors.publicClient.getBlockNumber();
const dhTxResult = await tx.signAndSubmit(alithSigner);
expect(dhTxResult.ok).toBe(true);
await waitForEthereumEvent({
client: connectors.publicClient,
address: erc20Address,
abi: erc20Abi,
eventName: "Transfer",
args: { from: ZERO_ADDRESS, to: recipient },
fromBlock: startBlock > 0n ? startBlock - 1n : startBlock,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS
});
// Verify DataHaven events
expect(
dhTxResult.events.find(
(e: any) =>
e.type === "DataHavenNativeTransfer" &&
e.value?.type === "TokensTransferredToEthereum" &&
e.value?.value?.from === SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
)
).toBeDefined();
expect(
dhTxResult.events.find(
(e: any) =>
e.type === "DataHavenNativeTransfer" &&
e.value?.type === "TokensLocked" &&
e.value?.value?.account === SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey
)
).toBeDefined();
// Capture final balances
const after = await getBalanceSnapshot(connectors, {
dhAccount: SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey,
ethAccount: recipient,
erc20Address
});
// Verify balance changes
expectBalanceDeltas(before, after, {
dhMin: amount + fee,
sovereign: amount,
erc20: amount
});
// Verify 1:1 backing ratio is maintained
const totalSupply = (await connectors.publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "totalSupply"
})) as bigint;
const sovereignBalance =
await connectors.dhApi.query.System.Account.getValue(ethereumSovereignAccount);
expect(sovereignBalance.data.free).toBeGreaterThanOrEqual(totalSupply);
},
CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS + CROSS_CHAIN_TIMEOUTS.EVENT_CONFIRMATION_MS
);
it(
"should transfer tokens from Ethereum to DataHaven",
async () => {
const connectors = suite.getTestConnectors();
const erc20Address = await requireNativeERC20Address(connectors);
const ethWalletClient = connectors.walletClient;
const ethereumSender = ethWalletClient.account.address as `0x${string}`;
const dhRecipient = SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey as `0x${string}`;
const amount = parseEther("5");
const executionFee = parseEther("0.1");
const relayerFee = parseEther("0.4");
// Capture initial balances and supply for ETH -> DH leg
const [before, initialTotalSupply] = await Promise.all([
getBalanceSnapshot(connectors, {
dhAccount: dhRecipient,
ethAccount: ethereumSender,
erc20Address
}),
connectors.publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "totalSupply"
}) as Promise<bigint>
]);
expect(before.erc20).toBeGreaterThanOrEqual(amount);
// Send tokens to DataHaven via Gateway
const sendHash = await ethWalletClient.writeContract({
address: deployments!.Gateway as `0x${string}`,
abi: gatewayAbi,
functionName: "v2_sendMessage",
args: [
"0x" as `0x${string}`,
[
encodeAbiParameters(
[
{ name: "kind", type: "uint8" },
{ name: "token", type: "address" },
{ name: "value", type: "uint128" }
],
[0, erc20Address, amount]
)
] as any,
dhRecipient,
executionFee,
relayerFee
],
value: executionFee + relayerFee,
chain: null
});
const sendReceipt = await connectors.publicClient.waitForTransactionReceipt({
hash: sendHash
});
expect(sendReceipt.status).toBe("success");
// Assert OutboundMessageAccepted event was emitted
const gatewayLogs = sendReceipt.logs!.filter(
(log) => log.address.toLowerCase() === deployments!.Gateway.toLowerCase()
);
const decodedEvents = parseEventLogs({ abi: gatewayAbi, logs: gatewayLogs });
const hasOutboundAccepted = decodedEvents.some(
(event) => event.eventName === "OutboundMessageAccepted"
);
expect(hasOutboundAccepted).toBe(true);
// Assert ERC20 was burned (Transfer to zero address)
const erc20Logs = sendReceipt.logs!.filter(
(log) => log.address.toLowerCase() === erc20Address.toLowerCase()
);
const transferEvents = parseEventLogs({ abi: erc20Abi, logs: erc20Logs });
const burnEvent = transferEvents.find(
(event) =>
event.eventName === "Transfer" &&
event.args.from?.toLowerCase() === ethereumSender.toLowerCase() &&
event.args.to?.toLowerCase() === ZERO_ADDRESS.toLowerCase() &&
event.args.value === amount
);
expect(burnEvent).toBeDefined();
// Wait for relay (takes ~2-3 min due to Ethereum finality)
await waitForDataHavenEvent<{ account: any; amount: bigint }>({
api: connectors.dhApi,
pallet: "DataHavenNativeTransfer",
event: "TokensUnlocked",
filter: (e) =>
String(e.account).toLowerCase() === dhRecipient.toLowerCase() && e.amount === amount,
timeout: CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS
});
// Final balances
const [after, finalTotalSupply] = await Promise.all([
getBalanceSnapshot(connectors, {
dhAccount: dhRecipient,
ethAccount: ethereumSender,
erc20Address
}),
connectors.publicClient.readContract({
address: erc20Address,
abi: erc20Abi,
functionName: "totalSupply"
}) as Promise<bigint>
]);
// Assertions: burn on Ethereum and unlock on DataHaven
expect(after.erc20).toBe(before.erc20 - amount);
expect(finalTotalSupply).toBe(initialTotalSupply - amount);
expectBalanceDeltas(before, after, {
dhExact: amount, // recipient gets exactly amount
sovereign: -amount // sovereign decreases by amount (unlocked)
});
},
CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS +
CROSS_CHAIN_TIMEOUTS.ETH_TO_DH_MS +
CROSS_CHAIN_TIMEOUTS.EVENT_CONFIRMATION_MS
); // includes funding (DH→ETH) + transfer (ETH→DH)
});