test: port common tests from moonbeam (#223)

## Overview
Ports essential test suites and helper utilities from Moonbeam's
battle-tested framework to validate DataHaven's EVM compatibility and
precompile functionality.

## What's Included

### 🧪 Test Suites
- **Contract Creation Tests** (`test-contract-creation.ts`)
  - EVM contract deployment verification
  - CREATE/CREATE2 opcode validation  
  - Smart contract nonce management
  - Bytecode storage and retrieval
  - Block fee verification

- **Precompile Batch Tests** (`test-precompile-batch.ts`)
  - Batch operation gas consumption
  - Recursive batch calls
  - Call permit integration
  - EIP-712 signature validation

### 🛠️ Helper Utilities
- **Block helpers**: Fee verification, transaction analysis, block
exploration
- **EVM helpers**: Result validation, signature parsing, receipt
handling
- **Fee helpers**: Treasury/burn split calculations
- **Runtime helpers**: Parameter fetching, versioned constants
- **Contract helpers**: Artifact loading, bytecode management

### 📝 Test Contracts
- `SimpleContractFactory`: CREATE/CREATE2 test harness
- `CallBatchFromConstructor`: Precompile integration tests
This commit is contained in:
Ahmad Kaouk 2025-10-28 12:03:40 +01:00 committed by GitHub
parent 782321e5d0
commit 82c7156fd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1218 additions and 142 deletions

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;
import "../../../../precompiles/batch/Batch.sol";
contract BatchCaller {
function inner(address to, bytes[] memory callData) internal {
address[] memory toAddress = new address[](1);
toAddress[0] = to;
uint256[] memory value = new uint256[](1);
value[0] = 0;
uint64[] memory gasLimit = new uint64[](1);
gasLimit[0] = 0;
BATCH_CONTRACT.batchAll(toAddress, value, callData, gasLimit);
}
}
contract CallBatchPrecompileFromConstructor is BatchCaller {
constructor(address to, bytes[] memory callData) {
inner(to, callData);
}
}
contract CallBatchPrecompileFromConstructorInSubCall {
CallBatchPrecompileFromConstructor public addr;
function simple(address to, bytes[] memory callData) external {
addr = new CallBatchPrecompileFromConstructor(to, callData);
}
}

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;
contract SimpleContract {
address public owner;
constructor() {
owner = msg.sender;
}
}
contract SimpleContractFactory {
SimpleContract[] public deployedWithCreate;
SimpleContract[] public deployedWithCreate2;
constructor() {
createSimpleContractWithCreate();
createSimpleContractWithCreate2(0);
}
function createSimpleContractWithCreate() public {
SimpleContract newContract = new SimpleContract();
deployedWithCreate.push(newContract);
}
function createSimpleContractWithCreate2(uint256 salt) public returns (address) {
bytes memory bytecode = type(SimpleContract).creationCode;
address addr;
assembly {
addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
if iszero(extcodesize(addr)) { revert(0, 0) }
}
deployedWithCreate2.push(SimpleContract(addr));
return addr;
}
function getDeployedWithCreate() public view returns (SimpleContract[] memory) {
return deployedWithCreate;
}
function getDeployedWithCreate2() public view returns (SimpleContract[] memory) {
return deployedWithCreate2;
}
}

View file

@ -0,0 +1,335 @@
import { type DevModeContext, expect } from "@moonwall/cli";
import {
type BlockRangeOption,
EXTRINSIC_BASE_WEIGHT,
mapExtrinsics,
WEIGHT_PER_GAS
} from "@moonwall/util";
import type { ApiPromise } from "@polkadot/api";
import type { TxWithEvent } from "@polkadot/api-derive/types";
import type { u128 } from "@polkadot/types";
import type { BlockHash, DispatchInfo, RuntimeDispatchInfo } from "@polkadot/types/interfaces";
import type { RuntimeDispatchInfoV1 } from "@polkadot/types/interfaces/payment";
import type { Block } from "@polkadot/types/interfaces/runtime/types";
import Debug from "debug";
import { calculateFeePortions } from "./fees";
import { getFeesTreasuryProportion } from "./parameters";
const debug = Debug("test:blocks");
export interface TxWithEventAndFee extends TxWithEvent {
fee: RuntimeDispatchInfo | RuntimeDispatchInfoV1;
}
export interface BlockDetails {
block: Block;
txWithEvents: TxWithEventAndFee[];
}
export const getBlockDetails = async (
api: ApiPromise,
blockHash: BlockHash | string | any
): Promise<BlockDetails> => {
debug(`Querying ${blockHash}`);
const [{ block }, records] = await Promise.all([
api.rpc.chain.getBlock(blockHash),
await (await api.at(blockHash)).query.system.events()
]);
const fees = await Promise.all(
block.extrinsics.map(async (ext) =>
(await api.at(block.header.parentHash)).call.transactionPaymentApi.queryInfo(
ext.toU8a(),
ext.encodedLength
)
)
);
const txWithEvents = mapExtrinsics(block.extrinsics, records, fees);
return {
block,
txWithEvents
} as any as BlockDetails;
};
// Explore all blocks for the given range and returns block information for each one
// fromBlockNumber and toBlockNumber included
export const exploreBlockRange = async (
api: ApiPromise,
{ from, to, concurrency = 1 }: BlockRangeOption,
callBack: (blockDetails: BlockDetails) => Promise<void>
) => {
let current = from;
while (current <= to) {
const concurrentTasks: any[] = [];
for (let i = 0; i < concurrency && current <= to; i++) {
concurrentTasks.push(
api.rpc.chain.getBlockHash(current++).then((hash) => getBlockDetails(api, hash))
);
}
const blocksDetails = await Promise.all(concurrentTasks);
for (const blockDetails of blocksDetails) {
await callBack(blockDetails);
}
}
};
export const verifyBlockFees = async (
context: DevModeContext,
fromBlockNumber: number,
toBlockNumber: number,
expectedBalanceDiff: bigint
) => {
const api = context.polkadotJs();
debug(`========= Checking block ${fromBlockNumber}...${toBlockNumber}`);
// let sumBlockFees = 0n;
let sumBlockBurnt = 0n;
// Get from block hash and totalSupply
const fromPreBlockHash = (await api.rpc.chain.getBlockHash(fromBlockNumber - 1)).toString();
const fromPreSupply = (await (
await api.at(fromPreBlockHash)
).query.balances.totalIssuance()) as any;
let previousBlockHash = fromPreBlockHash;
// Get to block hash and totalSupply
const toBlockHash = (await api.rpc.chain.getBlockHash(toBlockNumber)).toString();
const toSupply = (await (await api.at(toBlockHash)).query.balances.totalIssuance()) as any;
// fetch block information for all blocks in the range
await exploreBlockRange(
api,
{ from: fromBlockNumber, to: toBlockNumber, concurrency: 5 },
async (blockDetails) => {
// let blockFees = 0n;
let blockBurnt = 0n;
// iterate over every extrinsic
for (const txWithEvents of blockDetails.txWithEvents) {
const { events, extrinsic, fee } = txWithEvents;
// This hash will only exist if the transaction was executed through ethereum.
let ethereumAddress = "";
if (extrinsic.method.section === "ethereum") {
// Search for ethereum execution
events.forEach((event) => {
if (event.section === "ethereum" && event.method === "Executed") {
ethereumAddress = event.data[0].toString();
}
});
}
// Payment event is submitted for substrate transactions
const paymentEvent = events.find(
(event) => event.section === "transactionPayment" && event.method === "TransactionFeePaid"
);
let txFees = 0n;
let txBurnt = 0n;
// For every extrinsic, iterate over every event
// and search for ExtrinsicSuccess or ExtrinsicFailed
for (const event of events) {
if (
api.events.system.ExtrinsicSuccess.is(event) ||
api.events.system.ExtrinsicFailed.is(event)
) {
const dispatchInfo =
event.method === "ExtrinsicSuccess"
? (event.data[0] as DispatchInfo)
: (event.data[1] as DispatchInfo);
const feesTreasuryProportion = await getFeesTreasuryProportion(context);
// We are only interested in fee paying extrinsics:
// Either ethereum transactions or signed extrinsics with fees (substrate tx)
if (
(dispatchInfo.paysFee.isYes && !extrinsic.signer.isEmpty) ||
extrinsic.method.section === "ethereum"
) {
if (extrinsic.method.section === "ethereum") {
// For Ethereum tx we caluculate fee by first converting weight to gas
const gasUsed = (dispatchInfo as any).weight.refTime.toBigInt() / WEIGHT_PER_GAS;
const ethTxWrapper = extrinsic.method.args[0] as any;
const number = blockDetails.block.header.number.toNumber();
// The on-chain base fee used by the transaction. Aka the parent block's base fee.
//
// Note on 1559 fees: no matter what the user was willing to pay (maxFeePerGas),
// the transaction fee is ultimately computed using the onchain base fee. The
// additional tip eventually paid by the user (maxPriorityFeePerGas) is purely a
// prioritization component: the EVM is not aware of it and thus not part of the
// weight cost of the extrinsic.
// let baseFeePerGas = BigInt(
// (await context.web3().eth.getBlock(number - 1)).baseFeePerGas!
// );
const baseFeePerGas = (
await context.viem().getBlock({ blockNumber: BigInt(number - 1) })
).baseFeePerGas!;
let priorityFee: bigint;
let gasFee: bigint;
// Transaction is an enum now with as many variants as supported transaction types.
if (ethTxWrapper.isLegacy) {
priorityFee = ethTxWrapper.asLegacy.gasPrice.toBigInt();
gasFee = priorityFee;
} else if (ethTxWrapper.isEip2930) {
priorityFee = ethTxWrapper.asEip2930.gasPrice.toBigInt();
gasFee = priorityFee;
} else if (ethTxWrapper.isEip1559) {
priorityFee = ethTxWrapper.asEip1559.maxPriorityFeePerGas.toBigInt();
gasFee = ethTxWrapper.asEip1559.maxFeePerGas.toBigInt();
}
const hash = events
.find((event) => event.section === "ethereum" && event.method === "Executed")!
.data[2].toHex();
await context.viem("public").getTransactionReceipt({ hash });
let effectiveTipPerGas = gasFee - baseFeePerGas;
if (effectiveTipPerGas > priorityFee) {
effectiveTipPerGas = priorityFee;
}
// Calculate the fees paid for the base fee and tip fee independently.
// Only the base fee is subject to the split between burn and treasury.
let baseFeesPaid = gasUsed * baseFeePerGas;
let tipAsFeesPaid = gasUsed * effectiveTipPerGas;
const actualPaidFees = (
events.find(
(event) => event.section === "balances" && event.method === "Withdraw"
)!.data[1] as u128
).toBigInt();
if (actualPaidFees < baseFeesPaid + tipAsFeesPaid) {
baseFeesPaid = actualPaidFees < baseFeesPaid ? actualPaidFees : baseFeesPaid;
tipAsFeesPaid =
actualPaidFees < baseFeesPaid ? 0n : actualPaidFees - baseFeesPaid;
}
const { burnt: baseFeePortionsBurnt } = calculateFeePortions(
feesTreasuryProportion,
baseFeesPaid
);
txFees += baseFeesPaid + tipAsFeesPaid;
txBurnt += baseFeePortionsBurnt;
} else {
// For a regular substrate tx, we use the partialFee
const feePortions = calculateFeePortions(
feesTreasuryProportion,
fee.partialFee.toBigInt()
);
txFees += fee.partialFee.toBigInt() + extrinsic.tip.toBigInt();
txBurnt += feePortions.burnt;
// verify entire substrate txn fee
const apiAt = await context.polkadotJs().at(previousBlockHash);
const lengthFee = (
(await apiAt.call.transactionPaymentApi.queryLengthToFee(
extrinsic.encodedLength
)) as any
).toBigInt();
const unadjustedWeightFee = (
await apiAt.call.transactionPaymentApi.queryWeightToFee(
"refTime" in fee.weight
? fee.weight
: {
refTime: fee.weight,
proofSize: 0n
}
)
).toBigInt();
const multiplier = await apiAt.query.transactionPayment.nextFeeMultiplier();
const denominator = 1_000_000_000_000_000_000n;
const weightFee = (unadjustedWeightFee * multiplier.toBigInt()) / denominator;
const baseFee = (
(await apiAt.call.transactionPaymentApi.queryWeightToFee({
refTime: EXTRINSIC_BASE_WEIGHT,
proofSize: 0n
})) as any
).toBigInt();
const tip = extrinsic.tip.toBigInt();
const expectedPartialFee = lengthFee + weightFee + baseFee;
// Verify the computed fees are equal to the actual fees + tip
expect(expectedPartialFee + tip).to.eq((paymentEvent!.data[1] as u128).toBigInt());
expect(tip).to.eq((paymentEvent!.data[2] as u128).toBigInt());
// Verify the computed fees are equal to the rpc computed fees
expect(expectedPartialFee).to.eq(fee.partialFee.toBigInt());
}
// blockFees += txFees;
blockBurnt += txBurnt;
const origin = extrinsic.signer.isEmpty
? ethereumAddress
: extrinsic.signer.toString();
// Get balance of the origin account both before and after extrinsic execution
const fromBalance = (await (
await api.at(previousBlockHash)
).query.system.account(origin)) as any;
const toBalance = (await (
await api.at(blockDetails.block.hash)
).query.system.account(origin)) as any;
expect(txFees.toString()).to.eq(
(
(((fromBalance.data.free.toBigInt() as any) -
toBalance.data.free.toBigInt()) as any) - expectedBalanceDiff
).toString()
);
}
}
}
}
// sumBlockFees += blockFees;
sumBlockBurnt += blockBurnt;
previousBlockHash = blockDetails.block.hash.toString();
}
);
expect(fromPreSupply.toBigInt() - toSupply.toBigInt()).to.eq(sumBlockBurnt);
// Log difference in supply, we should be equal to the burnt fees
// debug(
// ` supply diff: ${(fromPreSupply.toBigInt() - toSupply.toBigInt())
// .toString()
// .padStart(30, " ")}`
// );
// debug(` burnt fees : ${sumBlockBurnt.toString().padStart(30, " ")}`);
// debug(` total fees : ${sumBlockFees.toString().padStart(30, " ")}`);
};
export const verifyLatestBlockFees = async (
context: DevModeContext,
expectedBalanceDiff: bigint = BigInt(0)
) => {
const signedBlock = await context.polkadotJs().rpc.chain.getBlock();
const blockNumber = Number(signedBlock.block.header.number);
return verifyBlockFees(context, blockNumber, blockNumber, expectedBalanceDiff);
};
export async function jumpToRound(context: DevModeContext, round: number): Promise<string | null> {
let lastBlockHash = "";
for (;;) {
const currentRound = (
await context.polkadotJs().query.parachainStaking.round()
).current.toNumber();
if (currentRound === round) {
return lastBlockHash;
}
if (currentRound > round) {
return null;
}
lastBlockHash = (await context.createBlock()).block.hash.toString();
}
}

View file

@ -1,63 +1,55 @@
/**
* Runtime constants for DataHaven networks
* Adapted from Moonbeam test helpers
*/
import type { GenericContext } from "@moonwall/cli";
/**
* Class allowing to store multiple value for a runtime constant based on the runtime version
*/
class RuntimeConstant<T> {
private values: { [version: number]: T };
private readonly values: Map<number, T>;
/*
* Get the expected value for a given runtime version. Lookup for the closest smaller runtime
*/
get(runtimeVersion: number): T {
const versions = Object.keys(this.values).map(Number); // slow but easier to maintain
let value: T | undefined;
for (let i = 0; i < versions.length; i++) {
if (versions[i] > runtimeVersion) {
break;
constructor(valuesByVersion: Record<number, T>) {
this.values = new Map(Object.entries(valuesByVersion).map(([k, v]) => [Number(k), v]));
}
get(version: number): T {
const sortedVersions = Array.from(this.values.keys()).sort((a, b) => b - a);
for (const v of sortedVersions) {
if (version >= v) {
return this.values.get(v)!;
}
value = this.values[versions[i]];
}
return value as T;
}
// Builds RuntimeConstant with single or multiple values
constructor(values: { [version: number]: T } | T) {
if (values instanceof Object) {
this.values = values;
} else {
this.values = { 0: values };
}
return this.values.get(0)!;
}
}
// Fees and gas limits
// Values derived from Rust runtime configuration in operator/runtime/*/src/lib.rs
export const RUNTIME_CONSTANTS = {
"DATAHAVEN-STAGENET": {
GAS_LIMIT: new RuntimeConstant(60_000_000n),
EXTRINSIC_GAS_LIMIT: new RuntimeConstant(52_000_000n),
BLOCK_WEIGHT_LIMIT: new RuntimeConstant(2_000_000_000_000n),
MAX_POV_SIZE: new RuntimeConstant(10_485_760n) // 10MB in bytes (matching Moonbeam)
},
"DATAHAVEN-MAINNET": {
GAS_LIMIT: new RuntimeConstant(60_000_000n),
EXTRINSIC_GAS_LIMIT: new RuntimeConstant(52_000_000n),
BLOCK_WEIGHT_LIMIT: new RuntimeConstant(2_000_000_000_000n),
MAX_POV_SIZE: new RuntimeConstant(10_485_760n) // 10MB in bytes (matching Moonbeam)
},
"DATAHAVEN-TESTNET": {
GAS_LIMIT: new RuntimeConstant(60_000_000n),
EXTRINSIC_GAS_LIMIT: new RuntimeConstant(52_000_000n),
BLOCK_WEIGHT_LIMIT: new RuntimeConstant(2_000_000_000_000n),
MAX_POV_SIZE: new RuntimeConstant(10_485_760n) // 10MB in bytes (matching Moonbeam)
const DATAHAVEN_CONSTANTS = {
BLOCK_WEIGHT_LIMIT: new RuntimeConstant({
0: 2_000_000_000_000n
}),
GAS_LIMIT: new RuntimeConstant({
0: 60_000_000n
}),
EXTRINSIC_GAS_LIMIT: new RuntimeConstant({
0: 52_000_000n
}),
WEIGHT_TO_GAS_RATIO: 25_000n,
STORAGE_READ_COST: 25_000_000n,
STORAGE_WRITE_COST: 50_000_000n,
SUPPLY_FACTOR: 1n,
PRECOMPILE_ADDRESSES: {
BATCH: "0x0000000000000000000000000000000000000808" as const,
CALL_PERMIT: "0x000000000000000000000000000000000000080a" as const,
PROXY: "0x000000000000000000000000000000000000080b" as const,
ERC20_BALANCES: "0x0000000000000000000000000000000000000802" as const,
PRECOMPILE_REGISTRY: "0x0000000000000000000000000000000000000815" as const
}
};
} as const;
type ConstantStoreType = (typeof RUNTIME_CONSTANTS)["DATAHAVEN-STAGENET"];
type ConstantStoreType = typeof DATAHAVEN_CONSTANTS;
export function ConstantStore(context: GenericContext): ConstantStoreType {
const runtime = context.polkadotJs().consts.system.version.specName.toUpperCase();
console.log("runtime", runtime);
return RUNTIME_CONSTANTS[runtime];
export function ConstantStore(_context: GenericContext): ConstantStoreType {
return DATAHAVEN_CONSTANTS;
}
export { RuntimeConstant };

View file

@ -1,20 +1,17 @@
import { readFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Abi } from "viem";
interface ArtifactEvmBytecode {
object?: string;
}
interface ArtifactEvm {
bytecode?: ArtifactEvmBytecode;
}
/**
* Contract-related helper utilities for DataHaven tests
* Adapted from Moonbeam test helpers
*/
interface ArtifactContract {
abi?: Abi;
evm?: ArtifactEvm;
bytecode?: `0x${string}`;
evm?: { bytecode?: { object?: string } };
}
interface CompiledContractArtifactJson {
@ -34,11 +31,28 @@ export interface CompiledContractArtifact {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const fetchCompiledContract = async (
contractName: string
): Promise<CompiledContractArtifact> => {
const artifactPath = path.join(__dirname, "../", "contracts", "out", `${contractName}.json`);
const artifactContent = await readFile(artifactPath, "utf-8");
export const fetchCompiledContract = (contractName: string): CompiledContractArtifact => {
let artifactPath = path.join(__dirname, "../", "contracts", "out", `${contractName}.json`);
if (!existsSync(artifactPath)) {
const folder = contractName
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/_/g, "-")
.toLowerCase()
.replace(/-+precompile$/, "");
const candidate = path.join(
__dirname,
"../",
"contracts",
"out",
"precompiles",
folder,
`${contractName}.json`
);
if (existsSync(candidate)) {
artifactPath = candidate;
}
}
const artifactContent = readFileSync(artifactPath, "utf-8");
const artifactJson = JSON.parse(artifactContent) as CompiledContractArtifactJson;
const abi = artifactJson.abi ?? artifactJson.contract.abi;

View file

@ -1,65 +0,0 @@
import type { Abi } from "viem";
import { fetchCompiledContract } from "./contracts";
export const TransactionTypes = ["legacy", "eip1559", "eip2930"] as const;
export interface DeployCompiledContractOptions {
type?: (typeof TransactionTypes)[number];
gas?: bigint;
gasPrice?: bigint;
maxFeePerGas?: bigint;
maxPriorityFeePerGas?: bigint;
value?: bigint;
}
export interface DeployCompiledContractResult {
hash: `0x${string}`;
contractAddress: `0x${string}` | null;
status: "success" | "reverted";
abi: Abi;
}
export const deployCompiledContract = async (
context: any,
contractName: string,
options: DeployCompiledContractOptions = {}
): Promise<DeployCompiledContractResult> => {
const artifact = await fetchCompiledContract(contractName);
const tx: any = {
data: artifact.bytecode,
gas: options.gas,
value: options.value ?? 0n
};
switch (options.type) {
case "legacy":
tx.type = "legacy";
if (options.gasPrice !== undefined) tx.gasPrice = options.gasPrice;
break;
case "eip2930":
tx.type = "eip2930";
if (options.gasPrice !== undefined) tx.gasPrice = options.gasPrice;
tx.accessList = [];
break;
default:
tx.type = "eip1559";
if (options.maxFeePerGas !== undefined) tx.maxFeePerGas = options.maxFeePerGas;
if (options.maxPriorityFeePerGas !== undefined)
tx.maxPriorityFeePerGas = options.maxPriorityFeePerGas;
break;
}
const hash: `0x${string}` = await context.viem().sendTransaction(tx);
if (typeof context.createBlock === "function") {
await context.createBlock();
}
const receipt = await context.viem().waitForTransactionReceipt({ hash });
return {
hash,
contractAddress: receipt.contractAddress,
status: receipt.status,
abi: artifact.abi
};
};

View file

@ -0,0 +1,159 @@
/**
* EVM-related helper utilities for DataHaven tests
* Adapted from Moonbeam test helpers
*/
import { type DevModeContext, expect } from "@moonwall/cli";
import type { EventRecord } from "@polkadot/types/interfaces";
import type {
EvmCoreErrorExitError,
EvmCoreErrorExitFatal,
EvmCoreErrorExitReason,
EvmCoreErrorExitRevert,
EvmCoreErrorExitSucceed
} from "@polkadot/types/lookup";
export type Errors = {
Succeed: EvmCoreErrorExitSucceed["type"];
Error: EvmCoreErrorExitError["type"];
Revert: EvmCoreErrorExitRevert["type"];
Fatal: EvmCoreErrorExitFatal["type"];
};
/**
* Validate EVM execution result from Ethereum.Executed events
*
* @param events - Array of event records from block execution
* @param resultType - Expected result type (Succeed, Error, Revert, Fatal)
* @param reason - Optional specific reason within the result type
*
* @example
* ```ts
* expectEVMResult(result.events, "Succeed");
* expectEVMResult(result.events, "Revert", "Reverted");
* ```
*/
export function expectEVMResult<T extends Errors, Type extends keyof T>(
events: EventRecord[],
resultType: Type,
reason?: T[Type]
) {
expect(events, "Missing events, probably failed execution").toHaveLength;
expect(events.length).toBeGreaterThan(0);
const ethereumExecuted = events.find(
({ event: { section, method } }) => section === "ethereum" && method === "Executed"
);
expect(ethereumExecuted, "Ethereum.Executed event not found").toBeDefined();
const ethereumResult = ethereumExecuted!.event.data[3] as EvmCoreErrorExitReason;
const _foundReason = ethereumResult.isError
? ethereumResult.asError.type
: ethereumResult.isFatal
? ethereumResult.asFatal.type
: ethereumResult.isRevert
? ethereumResult.asRevert.type
: ethereumResult.asSucceed.type;
expect(ethereumResult.type).toBe(resultType);
if (reason) {
if (ethereumResult.isError) {
expect(ethereumResult.asError.type).toBe(reason);
} else if (ethereumResult.isFatal) {
expect(ethereumResult.asFatal.type).toBe(reason);
} else if (ethereumResult.isRevert) {
expect(ethereumResult.asRevert.type).toBe(reason);
} else {
expect(ethereumResult.asSucceed.type).toBe(reason);
}
}
}
/**
* Extract signature parameters (r, s, v) from a hex signature string
*
* @param signature - Hex signature string
* @returns Object containing r, s, v components
*/
export function getSignatureParameters(signature: string): {
r: string;
s: string;
v: number;
} {
const r = signature.slice(0, 66); // 32 bytes
const s = `0x${signature.slice(66, 130)}`; // 32 bytes
let v = Number.parseInt(signature.slice(130, 132), 16); // 1 byte
if (![27, 28].includes(v)) {
v += 27;
}
return { r, s, v };
}
/**
* Get transaction receipt with retry logic for async block production
*
* @param context - Moonwall dev context
* @param hash - Transaction hash
* @param options - Retry configuration
* @returns Transaction receipt
*/
export async function getTransactionReceiptWithRetry(
context: DevModeContext,
hash: `0x${string}`,
options?: {
maxAttempts?: number;
delayMs?: number;
exponentialBackoff?: boolean;
}
) {
const maxAttempts = options?.maxAttempts ?? 4;
const delayMs = options?.delayMs ?? 2000;
const exponentialBackoff = options?.exponentialBackoff ?? true;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const receipt = await context.viem().getTransactionReceipt({ hash });
return receipt;
} catch (error: unknown) {
lastError = error as Error;
// Check if it's the specific error we want to retry
if (
(error as Error).name === "TransactionReceiptNotFoundError" ||
(error as Error).message?.includes("Transaction receipt with hash") ||
(error as Error).message?.includes("could not be found")
) {
if (attempt < maxAttempts) {
const delay = exponentialBackoff ? delayMs * 1.5 ** (attempt - 1) : delayMs;
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, 10000)));
continue;
}
}
// If it's a different error, throw immediately
throw error;
}
}
// If we've exhausted all attempts, throw the last error
throw lastError || new Error(`Failed to get transaction receipt after ${maxAttempts} attempts`);
}
/**
* Calculate total transaction fees (gasUsed * effectiveGasPrice)
*
* @param context - Moonwall dev context
* @param hash - Transaction hash
* @returns Total fees in wei
*/
export async function getTransactionFees(context: DevModeContext, hash: string): Promise<bigint> {
const receipt = await getTransactionReceiptWithRetry(context, hash as `0x${string}`);
return receipt.gasUsed * receipt.effectiveGasPrice;
}

View file

@ -0,0 +1,89 @@
import { BN } from "@polkadot/util";
// EIP-7623 Gas Cost Constants
// These constants define the gas costs under EIP-7623's floor cost mechanism
export const EIP7623_GAS_CONSTANTS = {
// Base transaction cost
BASE_TX_COST: 21000n,
// Token costs (EIP-7623 defines tokens = zero_bytes + nonzero_bytes * 4)
TOKENS_PER_NONZERO_BYTE: 4n,
TOKENS_PER_ZERO_BYTE: 1n,
// Floor costs per token
TOTAL_COST_FLOOR_PER_TOKEN: 10n,
// Derived floor costs per byte type
COST_FLOOR_PER_ZERO_BYTE: 10n, // 1 token * 10 gas/token
COST_FLOOR_PER_NON_ZERO_BYTE: 40n, // 4 tokens * 10 gas/token
// Standard (pre-EIP-7623) costs
STANDARD_COST_PER_ZERO_BYTE: 4n,
STANDARD_COST_PER_NON_ZERO_BYTE: 16n
} as const;
/**
* Calculate the expected gas cost with EIP-7623 floor cost mechanism
* @param numZeroBytes Number of zero bytes in calldata
* @param numNonZeroBytes Number of non-zero bytes in calldata
* @param executionGas Gas cost for the execution (e.g., contract execution, precompile)
* @returns Expected total gas used (maximum of floor cost and standard cost + execution)
*/
export function calculateEIP7623Gas(
numZeroBytes: number,
numNonZeroBytes: number,
executionGas = 0n
): bigint {
const {
BASE_TX_COST,
COST_FLOOR_PER_ZERO_BYTE,
COST_FLOOR_PER_NON_ZERO_BYTE,
STANDARD_COST_PER_ZERO_BYTE,
STANDARD_COST_PER_NON_ZERO_BYTE
} = EIP7623_GAS_CONSTANTS;
// Floor cost calculation
const floorCost =
BigInt(numNonZeroBytes) * COST_FLOOR_PER_NON_ZERO_BYTE +
BigInt(numZeroBytes) * COST_FLOOR_PER_ZERO_BYTE +
BASE_TX_COST;
// Standard cost + execution
const standardCalldataCost =
BigInt(numNonZeroBytes) * STANDARD_COST_PER_NON_ZERO_BYTE +
BigInt(numZeroBytes) * STANDARD_COST_PER_ZERO_BYTE;
const standardCostPlusExecution = standardCalldataCost + BASE_TX_COST + executionGas;
// Return the maximum of floor cost and standard cost + execution
return floorCost > standardCostPlusExecution ? floorCost : standardCostPlusExecution;
}
/// Recreation of fees.ration(burn_part, treasury_part)
export const split = (value: BN, part1: BN, part2: BN): [BN, BN] => {
const total = part1.add(part2);
if (total.eq(new BN(0)) || value.eq(new BN(0))) {
return [new BN(0), new BN(0)];
}
const part1BN = value.mul(part1).div(total);
const part2BN = value.sub(part1BN);
return [part1BN, part2BN];
};
export const calculateFeePortions = (
feesTreasuryProportion: bigint,
fees: bigint
): {
burnt: bigint;
treasury: bigint;
} => {
const feesBN = new BN(fees.toString());
const treasuryPartBN = new BN(feesTreasuryProportion.toString());
const burntPartBN = new BN(1e9).sub(treasuryPartBN);
const [burntBN, treasuryBN] = split(feesBN, burntPartBN, treasuryPartBN);
return {
burnt: BigInt(burntBN.toString()),
treasury: BigInt(treasuryBN.toString())
};
};

View file

@ -1,2 +1,14 @@
/**
* DataHaven test helpers
*
* This module exports helper utilities for writing Moonwall tests for DataHaven.
* These helpers are adapted from Moonbeam's test suite to work with DataHaven's
* runtime configuration.
*/
export * from "./block";
export * from "./constants";
export * from "./deploy";
export * from "./contracts";
export * from "./evm";
export * from "./fees";
export * from "./parameters";

View file

@ -0,0 +1,14 @@
import type { DevModeContext } from "@moonwall/cli";
export const getFeesTreasuryProportion = async (context: DevModeContext): Promise<bigint> => {
const parameter = await context.polkadotJs().query.parameters.parameters({
RuntimeConfig: "FeesTreasuryProportion"
});
// 20% default value
let feesTreasuryProportion = 200_000_000n;
if (parameter.isSome) {
feesTreasuryProportion = parameter.value.asRuntimeConfig.asFeesTreasuryProportion.toBigInt();
}
return feesTreasuryProportion;
};

View file

@ -1,5 +1,11 @@
import { beforeAll, describeSuite, expect } from "@moonwall/cli";
import { ConstantStore, deployCompiledContract, TransactionTypes } from "../../../../helpers";
import {
beforeAll,
deployCreateCompiledContract,
describeSuite,
expect,
TransactionTypes
} from "@moonwall/cli";
import { ConstantStore } from "../../../../helpers";
describeSuite({
id: "D010103",
@ -17,7 +23,7 @@ describeSuite({
id: `T0${TransactionTypes.indexOf(txnType) + 1}`,
title: `${txnType} should be allowed to the max block gas`,
test: async () => {
const { hash, status } = await deployCompiledContract(context, "MultiplyBy7", {
const { hash, status } = await deployCreateCompiledContract(context, "MultiplyBy7", {
type: txnType,
gas: ConstantStore(context).EXTRINSIC_GAS_LIMIT.get(specVersion)
});
@ -32,7 +38,7 @@ describeSuite({
title: `${txnType} should fail setting it over the max block gas`,
test: async () => {
await expect(async () =>
deployCompiledContract(context, "MultiplyBy7", {
deployCreateCompiledContract(context, "MultiplyBy7", {
type: txnType,
gas: ConstantStore(context).EXTRINSIC_GAS_LIMIT.get(specVersion) + 1n
})
@ -45,9 +51,13 @@ describeSuite({
id: "T07",
title: "should be accessible within a contract",
test: async () => {
const { contractAddress, abi } = await deployCompiledContract(context, "BlockVariables", {
gas: 500_000n
});
const { contractAddress, abi } = await deployCreateCompiledContract(
context,
"BlockVariables",
{
gas: 500_000n
}
);
if (!contractAddress) {
throw new Error("Expected deployed contract to have an address");

View file

@ -0,0 +1,169 @@
import {
deployCreateCompiledContract,
describeSuite,
expect,
fetchCompiledContract,
TransactionTypes
} from "@moonwall/cli";
import { ALITH_ADDRESS } from "@moonwall/util";
import { hexToU8a } from "@polkadot/util";
import { encodeDeployData, keccak256, numberToHex, toRlp } from "viem";
import { verifyLatestBlockFees } from "../../../../helpers";
describeSuite({
id: "D010201",
title: "Contract creation",
foundationMethods: "dev",
testCases: ({ context, it }) => {
for (const txnType of TransactionTypes) {
it({
id: `T0-${TransactionTypes.indexOf(txnType) + 1}`,
title: `should return the ${txnType} transaction hash`,
test: async () => {
const { hash } = await deployCreateCompiledContract(context, "MultiplyBy7", {
txnType: txnType as any
});
await context.createBlock();
expect(hash).toBeTruthy();
}
});
it({
id: `T0-${TransactionTypes.indexOf(txnType) + 4}`,
title: `${txnType} should return the contract code`,
test: async () => {
const compiled = fetchCompiledContract("MultiplyBy7");
const callCode = (await context.viem().call({ data: compiled.bytecode })).data;
await context.createBlock();
const { contractAddress } = await deployCreateCompiledContract(context, "MultiplyBy7", {
txnType: txnType as any,
gas: 5_000_000n
});
const deployedCode = await context.viem().getCode({ address: contractAddress! });
expect(callCode).to.be.eq(deployedCode);
}
});
it({
id: `T0-${TransactionTypes.indexOf(txnType) + 7}`,
title: `should not contain ${txnType} contract at genesis`,
test: async () => {
const { contractAddress } = await deployCreateCompiledContract(context, "MultiplyBy7", {
type: txnType as any,
gas: 5_000_000n
});
expect(
await context.viem().getCode({ address: contractAddress!, blockNumber: 0n })
).toBeUndefined();
}
});
it({
id: `T0-${TransactionTypes.indexOf(txnType) + 10}`,
title: `${txnType} deployed contracts should store the code on chain`,
test: async () => {
// This is to enable pending tag support
await context.createBlock();
const compiled = fetchCompiledContract("MultiplyBy7");
const callData = encodeDeployData({
abi: compiled.abi,
bytecode: compiled.bytecode,
args: []
}) as `0x${string}`;
const nonce = await context
.viem("public")
.getTransactionCount({ address: ALITH_ADDRESS });
await context.viem().sendTransaction({
data: callData,
nonce,
txnType: txnType as any,
gas: 5_000_000n
});
const contractAddress = ("0x" +
keccak256(hexToU8a(toRlp([ALITH_ADDRESS, numberToHex(nonce)])))
.slice(12)
.substring(14)) as `0x${string}`;
expect(
await context.viem("public").getCode({ address: contractAddress, blockTag: "pending" })
).to.deep.equal(compiled.deployedBytecode);
await context.createBlock();
expect(
await context.viem("public").getCode({ address: contractAddress, blockTag: "latest" })
).to.deep.equal(compiled.deployedBytecode);
}
});
it({
id: `T0-${TransactionTypes.indexOf(txnType) + 13}`,
title: `should check latest block fees for ${txnType}`,
test: async () => {
await context.createBlock();
await deployCreateCompiledContract(context, "Fibonacci", {
maxPriorityFeePerGas: 0n,
txnType: txnType as any
});
await verifyLatestBlockFees(context);
}
});
}
it({
id: "T1",
title: "Check smart-contract nonce increase when calling CREATE/CREATE2 opcodes",
test: async () => {
const factory = await context.deployContract!("SimpleContractFactory");
expect(await context.viem().getTransactionCount({ address: factory.contractAddress })).eq(
3
);
await context.writeContract!({
contractName: "SimpleContractFactory",
contractAddress: factory.contractAddress,
functionName: "createSimpleContractWithCreate",
value: 0n
});
await context.createBlock();
expect(await context.viem().getTransactionCount({ address: factory.contractAddress })).eq(
4
);
const deployedWithCreate = (await context.readContract!({
contractName: "SimpleContractFactory",
contractAddress: factory.contractAddress,
functionName: "getDeployedWithCreate",
args: []
})) as string[];
expect(deployedWithCreate.length).eq(2);
await context.writeContract!({
contractName: "SimpleContractFactory",
contractAddress: factory.contractAddress,
functionName: "createSimpleContractWithCreate2",
args: [1],
value: 0n
});
await context.createBlock();
expect(await context.viem().getTransactionCount({ address: factory.contractAddress })).eq(
5
);
const deployedWithCreate2 = (await context.readContract!({
contractName: "SimpleContractFactory",
contractAddress: factory.contractAddress,
functionName: "getDeployedWithCreate2",
args: []
})) as string[];
expect(deployedWithCreate2.length).eq(2);
}
});
}
});

View file

@ -0,0 +1,272 @@
import { describeSuite, expect, fetchCompiledContract } from "@moonwall/cli";
import {
ALITH_ADDRESS,
ALITH_PRIVATE_KEY,
BALTATHAR_ADDRESS,
BALTATHAR_PRIVATE_KEY,
CHARLETH_ADDRESS,
createViemTransaction,
sendRawTransaction
} from "@moonwall/util";
import { encodeFunctionData, fromHex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { ConstantStore, expectEVMResult, getSignatureParameters } from "../../../../helpers";
const ALITH_ACCOUNT = privateKeyToAccount(ALITH_PRIVATE_KEY);
// Precompile addresses for DataHaven
const PRECOMPILE_BATCH_ADDRESS = "0x0000000000000000000000000000000000000808";
const PRECOMPILE_CALL_PERMIT_ADDRESS = "0x000000000000000000000000000000000000080a";
describeSuite({
id: "D010312",
title: "Batch Precompile",
foundationMethods: "dev",
testCases: ({ context, it }) => {
it({
id: "T01",
title: "all batch functions should consume similar gas",
test: async () => {
const { abi: batchInterface } = fetchCompiledContract("Batch");
// each tx have a different gas limit to ensure it doesn't impact gas used
const batchAllTx = await createViemTransaction(context, {
to: PRECOMPILE_BATCH_ADDRESS,
gas: 1114112n,
data: encodeFunctionData({
abi: batchInterface,
functionName: "batchAll",
args: [
[BALTATHAR_ADDRESS, CHARLETH_ADDRESS],
["1000000000000000000", "2000000000000000000"],
[],
[]
]
})
});
const batchSomeTx = await createViemTransaction(context, {
to: PRECOMPILE_BATCH_ADDRESS,
gas: 1179648n,
nonce: 1,
data: encodeFunctionData({
abi: batchInterface,
functionName: "batchSome",
args: [
[BALTATHAR_ADDRESS, CHARLETH_ADDRESS],
["1000000000000000000", "2000000000000000000"],
[],
[]
]
})
});
const batchSomeUntilFailureTx = await createViemTransaction(context, {
to: PRECOMPILE_BATCH_ADDRESS,
gas: 1245184n,
nonce: 2,
data: encodeFunctionData({
abi: batchInterface,
functionName: "batchSomeUntilFailure",
args: [
[BALTATHAR_ADDRESS, CHARLETH_ADDRESS],
["1000000000000000000", "2000000000000000000"],
[],
[]
]
})
});
const batchAllResult = await sendRawTransaction(context, batchAllTx);
const batchSomeResult = await sendRawTransaction(context, batchSomeTx);
const batchSomeUntilFailureResult = await sendRawTransaction(
context,
batchSomeUntilFailureTx
);
await context.createBlock();
const batchAllReceipt = await context
.viem()
.getTransactionReceipt({ hash: batchAllResult as `0x${string}` });
const batchSomeReceipt = await context
.viem()
.getTransactionReceipt({ hash: batchSomeResult as `0x${string}` });
const batchSomeUntilFailureReceipt = await context
.viem()
.getTransactionReceipt({ hash: batchSomeUntilFailureResult as `0x${string}` });
const STORAGE_READ_GAS_COST = // One storage read gas cost
ConstantStore(context).STORAGE_READ_COST / ConstantStore(context).WEIGHT_TO_GAS_RATIO;
// All batch functions should use similar gas (within reasonable tolerance)
// The actual gas used includes one storage read for balance check
const expectedGas = 43932n + STORAGE_READ_GAS_COST;
expect(batchAllReceipt.gasUsed).toBe(expectedGas);
expect(batchSomeReceipt.gasUsed).toBe(expectedGas);
expect(batchSomeUntilFailureReceipt.gasUsed).toBe(expectedGas);
}
});
it({
id: "T02",
title: "batch should be able to call itself",
test: async () => {
const { abi: batchInterface } = fetchCompiledContract("Batch");
const batchAll = await context.writeContract!({
contractAddress: PRECOMPILE_BATCH_ADDRESS,
contractName: "Batch",
functionName: "batchAll",
args: [
[PRECOMPILE_BATCH_ADDRESS],
[],
[
encodeFunctionData({
abi: batchInterface,
functionName: "batchAll",
args: [
[BALTATHAR_ADDRESS, CHARLETH_ADDRESS],
["1000000000000000000", "2000000000000000000"],
[],
[]
]
})
],
[]
],
rawTxOnly: true
});
const { result } = await context.createBlock(batchAll);
expectEVMResult(result!.events, "Succeed");
}
});
it({
id: "T03",
title: "batch should be callable from call permit precompile",
test: async () => {
const { abi: batchInterface } = fetchCompiledContract("Batch");
const { abi: callPermitAbi } = fetchCompiledContract("CallPermit");
const alithNonceResult = (
await context.viem().call({
to: PRECOMPILE_CALL_PERMIT_ADDRESS,
data: encodeFunctionData({
abi: callPermitAbi,
functionName: "nonces",
args: [ALITH_ADDRESS]
})
})
).data;
const batchData = encodeFunctionData({
abi: batchInterface,
functionName: "batchAll",
args: [
[BALTATHAR_ADDRESS, CHARLETH_ADDRESS],
["1000000000000000000", "2000000000000000000"],
[],
[]
]
});
const chainId = await context.viem().getChainId();
const signature = await ALITH_ACCOUNT.signTypedData({
types: {
EIP712Domain: [
{
name: "name",
type: "string"
},
{
name: "version",
type: "string"
},
{
name: "chainId",
type: "uint256"
},
{
name: "verifyingContract",
type: "address"
}
],
CallPermit: [
{
name: "from",
type: "address"
},
{
name: "to",
type: "address"
},
{
name: "value",
type: "uint256"
},
{
name: "data",
type: "bytes"
},
{
name: "gaslimit",
type: "uint64"
},
{
name: "nonce",
type: "uint256"
},
{
name: "deadline",
type: "uint256"
}
]
},
primaryType: "CallPermit",
domain: {
name: "Call Permit Precompile",
version: "1",
chainId: Number(chainId),
verifyingContract: PRECOMPILE_CALL_PERMIT_ADDRESS
},
message: {
from: ALITH_ADDRESS,
to: PRECOMPILE_BATCH_ADDRESS,
value: 0n,
data: batchData,
gaslimit: 200_000n,
nonce: fromHex(alithNonceResult!, "bigint"),
deadline: 9999999999n
}
});
const { v, r, s } = getSignatureParameters(signature);
const { result: baltatharForAlithResult } = await context.createBlock(
await createViemTransaction(context, {
privateKey: BALTATHAR_PRIVATE_KEY,
to: PRECOMPILE_CALL_PERMIT_ADDRESS,
data: encodeFunctionData({
abi: callPermitAbi,
functionName: "dispatch",
args: [
ALITH_ADDRESS,
PRECOMPILE_BATCH_ADDRESS,
0,
batchData,
200_000,
9999999999,
v,
r,
s
]
})
})
);
expectEVMResult(baltatharForAlithResult!.events, "Succeed");
}
});
}
});

View file

@ -7,21 +7,19 @@
{
"name": "dev_datahaven",
"testFileDir": [
"suites/dev"
"datahaven/suites/dev"
],
"include": [
"**/*test*.ts"
],
"timeout": 180000,
"multiThreads": 4,
"contracts": "contracts/",
"contracts": "datahaven/contracts/",
"runScripts": [
"compile-contracts.sh compile"
],
"envVars": [
"DEBUG_COLORS=1",
"RUST_BACKTRACE=1",
"RUST_LOG=info"
"DEBUG_COLORS=1"
],
"reporters": [
"basic",