diff --git a/test/datahaven/contracts/src/CallBatchFromConstructor.sol b/test/datahaven/contracts/src/CallBatchFromConstructor.sol new file mode 100644 index 00000000..a9f6ee3f --- /dev/null +++ b/test/datahaven/contracts/src/CallBatchFromConstructor.sol @@ -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); + } +} diff --git a/test/datahaven/contracts/src/SimpleContractFactory.sol b/test/datahaven/contracts/src/SimpleContractFactory.sol new file mode 100644 index 00000000..eedb9573 --- /dev/null +++ b/test/datahaven/contracts/src/SimpleContractFactory.sol @@ -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; + } +} diff --git a/test/datahaven/helpers/block.ts b/test/datahaven/helpers/block.ts new file mode 100644 index 00000000..9ca128d5 --- /dev/null +++ b/test/datahaven/helpers/block.ts @@ -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 => { + 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 +) => { + 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 { + 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(); + } +} diff --git a/test/datahaven/helpers/constants.ts b/test/datahaven/helpers/constants.ts index 575e38ec..fe954d30 100644 --- a/test/datahaven/helpers/constants.ts +++ b/test/datahaven/helpers/constants.ts @@ -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 { - private values: { [version: number]: T }; + private readonly values: Map; - /* - * 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) { + 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 }; diff --git a/test/datahaven/helpers/contracts.ts b/test/datahaven/helpers/contracts.ts index 130d59f5..b2b2f618 100644 --- a/test/datahaven/helpers/contracts.ts +++ b/test/datahaven/helpers/contracts.ts @@ -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 => { - 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; diff --git a/test/datahaven/helpers/deploy.ts b/test/datahaven/helpers/deploy.ts deleted file mode 100644 index 960f4c53..00000000 --- a/test/datahaven/helpers/deploy.ts +++ /dev/null @@ -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 => { - 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 - }; -}; diff --git a/test/datahaven/helpers/evm.ts b/test/datahaven/helpers/evm.ts new file mode 100644 index 00000000..146609f4 --- /dev/null +++ b/test/datahaven/helpers/evm.ts @@ -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( + 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 { + const receipt = await getTransactionReceiptWithRetry(context, hash as `0x${string}`); + return receipt.gasUsed * receipt.effectiveGasPrice; +} diff --git a/test/datahaven/helpers/fees.ts b/test/datahaven/helpers/fees.ts new file mode 100644 index 00000000..75acd9b0 --- /dev/null +++ b/test/datahaven/helpers/fees.ts @@ -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()) + }; +}; diff --git a/test/datahaven/helpers/index.ts b/test/datahaven/helpers/index.ts index 5feb05cc..6f310b97 100644 --- a/test/datahaven/helpers/index.ts +++ b/test/datahaven/helpers/index.ts @@ -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"; diff --git a/test/datahaven/helpers/parameters.ts b/test/datahaven/helpers/parameters.ts new file mode 100644 index 00000000..df4b3550 --- /dev/null +++ b/test/datahaven/helpers/parameters.ts @@ -0,0 +1,14 @@ +import type { DevModeContext } from "@moonwall/cli"; + +export const getFeesTreasuryProportion = async (context: DevModeContext): Promise => { + 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; +}; diff --git a/test/datahaven/suites/dev/common/test-block/test-block-gas.ts b/test/datahaven/suites/dev/common/test-block/test-block-gas.ts index 3b372976..e7a272d3 100644 --- a/test/datahaven/suites/dev/common/test-block/test-block-gas.ts +++ b/test/datahaven/suites/dev/common/test-block/test-block-gas.ts @@ -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"); diff --git a/test/datahaven/suites/dev/common/test-contract/test-contract-creation.ts b/test/datahaven/suites/dev/common/test-contract/test-contract-creation.ts new file mode 100644 index 00000000..10a69aac --- /dev/null +++ b/test/datahaven/suites/dev/common/test-contract/test-contract-creation.ts @@ -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); + } + }); + } +}); diff --git a/test/datahaven/suites/dev/common/test-precompile/test-precompile-batch.ts b/test/datahaven/suites/dev/common/test-precompile/test-precompile-batch.ts new file mode 100644 index 00000000..64b013b4 --- /dev/null +++ b/test/datahaven/suites/dev/common/test-precompile/test-precompile-batch.ts @@ -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"); + } + }); + } +}); diff --git a/test/moonwall.config.json b/test/moonwall.config.json index 322ff771..f21bc6ee 100644 --- a/test/moonwall.config.json +++ b/test/moonwall.config.json @@ -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",