mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
## Summary Reorganizes the test directory structure for better clarity and maintainability: - **Rename `test/datahaven/` → `test/moonwall/`**: Clearly identifies these as Moonwall single-node tests - **Move `test/framework/` → `test/e2e/framework/`**: Groups e2e test utilities under a dedicated folder - **Move `test/suites/` → `test/e2e/suites/`**: Groups e2e test suites with the framework - **Add `test/e2e/framework/validators.ts`**: Extracts validator test helpers from utils into the e2e framework - **Update documentation**: README.md and E2E_FRAMEWORK_OVERVIEW.md reflect the new structure ### New Directory Structure ``` test/ ├── e2e/ │ ├── suites/ # E2E test suites (Kurtosis-based) │ └── framework/ # E2E test utilities & helpers ├── moonwall/ # Moonwall single-node tests ├── launcher/ # Network deployment tools └── ... ``` --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
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();
|
|
} else {
|
|
throw new Error("Unsupported Ethereum transaction type");
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|