/** * 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, opts: { dhAccount?: string; ethAccount?: `0x${string}`; erc20Address?: `0x${string}` } ): Promise { 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; 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 ]); 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 ]); // 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) });