mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
## 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>
438 lines
15 KiB
TypeScript
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)
|
|
});
|