mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
Update the 3 DataHaven environments' chain IDs & native token ticker as
follows:
* **Mainnet**
* **Chain ID**: 55930
* **Ticker**: HAVE
* **TestNet**
* **Chain ID**: 55931
* **Ticker**: MOCK
* **Stagenet**
* **Chain ID**: 55932
* **Ticker**: STAGE
The PR includes a storage migration for the Stagenet & Testnet
environments, that are already live, to update the EVM Chain ID stored
in the `pallet-evm-chain-id` pallet.
Note: the token symbol will only be updated with the genesis config
presets or newly generated chain specs. For already live networks, the
existing chain spec must be updated (i.e. the tokenSymbol property
changed) and used by all nodes in the network. This change in the chain
spec will not alter the chain genesis so it safe to do (in the very
early stages of the chain obviously).
---------
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit a97f0547a9)
596 lines
21 KiB
TypeScript
596 lines
21 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,
|
|
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("STAGE"),
|
|
symbol: Binary.fromText("wSTAGE"),
|
|
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("STAGE");
|
|
expect(tokenSymbol).toBe("wSTAGE");
|
|
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
|
|
});
|