diff --git a/tests/smoke-tests/test-block-weight-per-class.ts b/tests/smoke-tests/test-block-weight-per-class.ts deleted file mode 100644 index 6499359b3bc..00000000000 --- a/tests/smoke-tests/test-block-weight-per-class.ts +++ /dev/null @@ -1,106 +0,0 @@ -import "@moonbeam-network/api-augment"; -import { BN } from "@polkadot/util"; -import { expect } from "chai"; -import { describeSmokeSuite } from "../util/setup-smoke-tests"; - -const debug = require("debug")("smoke:proxy"); - -interface BlockWeights { - hash: string; - weights: BlockLimits; -} - -interface BlockLimits { - normal: BN; - operational: BN; -} - -describeSmokeSuite(`Verify block weight per class`, (context) => { - let blockLimits: BlockLimits; - let blockWeights: [BlockWeights?] = []; - - before("Retrieve all weight limits and usage", async function () { - this.timeout(240000); - // Number of total blocks we want to test - const batchOf = process.env.ROUNDS_TO_WAIT - ? Math.floor( - context.polkadotApi.consts.parachainStaking.defaultBlocksPerRound.toNumber() * - Number(process.env.ROUND_TO_WAIT) - ) - : process.env.BATCH_OF - ? parseInt(process.env.BATCH_OF) - : 300; - // Number of blocks to resolve at once - const concurrency = process.env.CONCURRENCY ? parseInt(process.env.CONCURRENCY) : 10; - - // Promise batch - const promiseBatch = batchOf / concurrency; - - // Block weight limits per class - const limits = await context.polkadotApi.consts.system.blockWeights; - blockLimits = { - normal: new BN(limits.perClass.normal.maxTotal.toJSON() as number), - operational: new BN(limits.perClass.operational.maxTotal.toJSON() as number), - }; - - // Best and oldest - const targetBlock = (await context.polkadotApi.rpc.chain.getHeader()).number.toNumber(); - let currentBlock = targetBlock - batchOf - 1; - - // Batch - for (let i = 0; i < promiseBatch; i++) { - await Promise.all( - Array.from(Array(concurrency).keys()).map(async () => { - currentBlock++; - const blockHash = await context.polkadotApi.rpc.chain.getBlockHash(currentBlock); - const apiAt = await context.polkadotApi.at(blockHash); - const specVersion = (await apiAt.query.system.lastRuntimeUpgrade()) - .unwrap() - .specVersion.toNumber(); - // RT 1700 introduces CheckWeight in pre_dispatch_self_contained, we only test after - // https://github.com/paritytech/frontier/pull/749 - if (specVersion >= 1700) { - const { normal, operational } = await apiAt.query.system.blockWeight(); - blockWeights.push({ - hash: blockHash.toString(), - weights: { - normal, - operational, - }, - }); - } - }) - ); - } - const len = blockWeights.length; - // Expected length - expect(len).to.be.eq(batchOf); - // Expected block hash uniqueness - expect([...new Set(blockWeights.map((item) => item.hash))].length).to.be.eq(len); - }); - - // Normal class - it("normal usage should be less than normal dispatch class limits", async function () { - for (const block of blockWeights) { - let used = block.weights.normal; - let allowed = blockLimits.normal; - expect(used.lte(allowed)).to.be.eq( - true, - `${block.hash} normal usage above allowed. Used ${used} and allowed ${allowed}.` - ); - } - debug(`Verified normal dispatch class`); - }); - // Operational class - it("operational usage should be less than operational dispatch class limits", async function () { - for (const block of blockWeights) { - let used = block.weights.operational; - let allowed = blockLimits.operational; - expect(used.lte(allowed)).to.be.eq( - true, - `${block.hash} operational usage above allowed. Used ${used} and allowed ${allowed}.` - ); - } - debug(`Verified operational dispatch class`); - }); -}); diff --git a/tests/smoke-tests/test-block-weights.ts b/tests/smoke-tests/test-block-weights.ts new file mode 100644 index 00000000000..5001598092b --- /dev/null +++ b/tests/smoke-tests/test-block-weights.ts @@ -0,0 +1,278 @@ +import "@moonbeam-network/api-augment"; +import { BN } from "@polkadot/util"; +import { expect } from "chai"; +import { describeSmokeSuite } from "../util/setup-smoke-tests"; +import Bottleneck from "bottleneck"; +import { fetchHistoricBlockNum, getBlockTime } from "../util/block"; +import { WEIGHT_PER_GAS } from "../util/constants"; +import { FrameSystemEventRecord } from "@polkadot/types/lookup"; + +const debug = require("debug")("smoke:weights"); + +const timePeriod = process.env.TIME_PERIOD ? Number(process.env.TIME_PERIOD) : 2 * 60 * 60 * 1000; +const limiter = new Bottleneck({ maxConcurrent: 10 }); + +interface BlockInfo { + blockNum: number; + hash: string; + weights: { + normal: BN; + operational: BN; + mandatory: BN; + }; + events: FrameSystemEventRecord[]; +} + +interface BlockLimits { + normal: BN; + operational: BN; +} + +describeSmokeSuite( + `Verify weights of blocks in the past ${(timePeriod / (1000 * 60 * 60)).toFixed(2)} hours`, + (context) => { + let blockLimits: BlockLimits; + let blockInfoArray: BlockInfo[]; + + before("Retrieve all weight limits and usage", async function () { + this.timeout(240000); + + const signedBlock = await context.polkadotApi.rpc.chain.getBlock( + await context.polkadotApi.rpc.chain.getFinalizedHead() + ); + + const lastBlockNumber = signedBlock.block.header.number.toNumber(); + const lastBlockTime = getBlockTime(signedBlock); + + const firstBlockTime = lastBlockTime - timePeriod; + debug(`Searching for the block at: ${new Date(firstBlockTime)}`); + const firstBlockNumber = (await limiter.wrap(fetchHistoricBlockNum)( + context.polkadotApi, + lastBlockNumber, + firstBlockTime + )) as number; + + const length = lastBlockNumber - firstBlockNumber; + const blockNumArray = Array.from({ length }, (_, i) => firstBlockNumber + i); + const limits = context.polkadotApi.consts.system.blockWeights; + + const getLimits = async (blockNum: number) => { + const blockHash = await context.polkadotApi.rpc.chain.getBlockHash(blockNum); + const apiAt = await context.polkadotApi.at(blockHash); + const specVersion = apiAt.consts.system.version.specVersion.toNumber(); + const events = await apiAt.query.system.events(); + + if (specVersion >= 1700) { + const { normal, operational, mandatory } = await apiAt.query.system.blockWeight(); + return { + blockNum, + hash: blockHash.toString(), + weights: { + normal, + operational, + mandatory, + }, + events, + }; + } + }; + + blockLimits = { + normal: new BN(limits.perClass.normal.maxTotal.toJSON() as number), + operational: new BN(limits.perClass.operational.maxTotal.toJSON() as number), + }; + blockInfoArray = await Promise.all( + blockNumArray.map((num) => limiter.schedule(() => getLimits(num))) + ); + }); + + // This test is more for verifying that the test code is correctly returning good quality data + // that the rest of the test suite performs verification on + it("should be returning unique block hashes in array", async () => { + const hashes = blockInfoArray.map((a) => a.hash); + const set = new Set(hashes); + expect(hashes.length, "Duplicate hashes in retrieved data, investigate test").to.be.equal( + set.size + ); + }); + + // Normal class + it("normal usage should be less than normal dispatch class limits", async function () { + const overweight = blockInfoArray + .filter((a) => a.weights.normal.gt(blockLimits.normal)) + .map((a) => { + debug( + `Block #${a.blockNum} has weight ${Number(a.weights.normal)} which is above limit!` + ); + return a; + }); + expect( + overweight, + `These blocks have normal weights in excess of limit, investigate: ${overweight + .map((a) => a.blockNum) + .join(", ")}` + ).to.be.empty; + }); + + // Operational class + it("operational usage should be less than dispatch class limits", async function () { + const overweight = blockInfoArray + .filter((a) => a.weights.operational.gt(blockLimits.operational)) + .map((a) => { + debug( + `Block #${a.blockNum} has weight ${Number(a.weights.operational)} which is above limit!` + ); + return a; + }); + expect( + overweight, + `These blocks have operational weights in excess of limit, investigate: ${overweight + .map((a) => a.blockNum) + .join(", ")}` + ).to.be.empty; + }); + + // This will test that when Block is 20%+ full, its normal weight is mostly explained + // by eth signed transactions. + it("should roughly have a block weight mostly composed of transactions", async function () { + this.timeout(120000); + debug( + `Checking #${blockInfoArray[0].blockNum} - #${ + blockInfoArray[blockInfoArray.length - 1].blockNum + } block weight proportions.` + ); + + const checkBlockWeight = async (blockInfo: BlockInfo) => { + const apiAt = await context.polkadotApi.at(blockInfo.hash); + + const normalWeight = Number(blockInfo.weights.normal); + const maxWeight = blockLimits.normal; + const ethBlock = (await apiAt.query.ethereum.currentBlock()).unwrap(); + + const actualWeightUsed = normalWeight / Number(maxWeight); + if (actualWeightUsed > 0.2) { + const gasUsed = ethBlock.header.gasUsed.toBigInt(); + const weightCalc = gasUsed * WEIGHT_PER_GAS; + const newRatio = (normalWeight - Number(weightCalc)) / Number(maxWeight); + if (newRatio > 0.2) { + debug( + `Block #${blockInfo.blockNum} is ${(actualWeightUsed * 100).toFixed(2)}% full with ${ + ethBlock.transactions.length + } transactions, non-transaction weight: ${(newRatio * 100).toFixed(2)}%` + ); + } + return { blockNum: blockInfo.blockNum, nonTxn: newRatio }; + } + }; + + const results = await Promise.all( + blockInfoArray.map((blockInfo) => limiter.schedule(() => checkBlockWeight(blockInfo))) + ); + const nonTxnHeavyBlocks = results.filter((a) => a && a.nonTxn > 0.2); + expect( + nonTxnHeavyBlocks, + `These blocks have non-txn weights >20%, please investigate: ${nonTxnHeavyBlocks + .map((a) => a.blockNum) + .join(", ")}` + ).to.be.empty; + }); + + // This will test that the total normal weight reported is roughly the sum of normal class + // weight events emitted by signed extrinsics + it("should have total normal weight matching the signed extrinsics", async function () { + this.timeout(120000); + debug( + `Checking if #${blockInfoArray[0].blockNum} - #${ + blockInfoArray[blockInfoArray.length - 1].blockNum + } extrinsic weights sum up.` + ); + + const checkWeights = (blockInfo: BlockInfo) => { + const signedExtTotal = blockInfo.events + .filter( + (a) => a.event.method == "ExtrinsicSuccess" || a.event.method == "ExtrinsicFailed" + ) + .filter((a) => (a.event.data as any).dispatchInfo.class.toString() == "Normal") + .reduce((acc, curr) => acc + (curr.event.data as any).dispatchInfo.weight.toNumber(), 0); + const normalWeights = Number(blockInfo.weights.normal); + const difference = (normalWeights - signedExtTotal) / signedExtTotal; + if (difference > 0.2) { + debug( + `Block #${blockInfo.blockNum} signed extrinsic weight - reported: ${signedExtTotal}, + accounted: ${normalWeights} (${difference > 0 ? "+" : "-"}${(difference * 100).toFixed( + 2 + )}%).` + ); + } + return { blockNum: blockInfo.blockNum, signedExtTotal, normalWeights, difference }; + }; + + const results = blockInfoArray.map((blockInfo) => checkWeights(blockInfo)); + const heavyweights = results.filter((a) => a.difference > 0.2); + expect( + heavyweights, + `These blocks have >20% overweight normal weights, please investigate: ${heavyweights + .map((a) => a.blockNum) + .join(", ")}` + ).to.be.empty; + }); + + // This test will compare the total weight of eth transactions versus the reported gasUsed + // property of ethereum.currentBlock() + it("should have total gas charged similar to eth extrinsics", async function () { + this.timeout(120000); + debug( + `Checking if #${blockInfoArray[0].blockNum} - #${ + blockInfoArray[blockInfoArray.length - 1].blockNum + } weights match gasUsed` + ); + + const compareGasToWeight = async (blockInfo: BlockInfo) => { + const apiAt = await context.polkadotApi.at(blockInfo.hash); + const signedBlock = await context.polkadotApi.rpc.chain.getBlock(blockInfo.hash); + const gasUsed = (await apiAt.query.ethereum.currentBlock()) + .unwrap() + .header.gasUsed.toNumber(); + + const gasWeight = gasUsed * Number(WEIGHT_PER_GAS); + const ethTxnsWeight = signedBlock.block.extrinsics + .map((item, index) => { + if (item.method.method == "transact" && item.method.section == "ethereum") { + return blockInfo.events + .filter(({ phase }) => phase.isApplyExtrinsic && phase.asApplyExtrinsic.eq(index)) + .filter( + ({ event }) => event.method == "ExtrinsicSuccess" && event.section == "system" + ) + .reduce( + (acc, curr) => acc + (curr.event.data as any).dispatchInfo.weight.toNumber(), + 0 + ); + } else { + return 0; + } + }) + .reduce((acc, curr) => acc + curr, 0); + const difference = ethTxnsWeight - gasWeight; + + if (difference > 0) { + debug( + `Block #${blockInfo.blockNum} has a ${((difference / ethTxnsWeight) * 100).toFixed( + 2 + )}% discrepancy between eth gas used and weight charged. ` + ); + } + return { blockNum: blockInfo.blockNum, gasWeight, ethTxnsWeight, difference }; + }; + + const results = await Promise.all( + blockInfoArray.map((blockInfo) => limiter.schedule(() => compareGasToWeight(blockInfo))) + ); + const discrepancies = results.filter((a) => a.difference > 0); + expect( + discrepancies, + `These blocks have mismatching gas used vs charged weight, + please investigate: ${discrepancies.map((a) => a.blockNum).join(", ")}` + ).to.be.empty; + }); + } +); diff --git a/tests/tests/test-eth-fee/test-eth-txn-weights.ts b/tests/tests/test-eth-fee/test-eth-txn-weights.ts index 88426acf584..b2fd4b50a04 100644 --- a/tests/tests/test-eth-fee/test-eth-txn-weights.ts +++ b/tests/tests/test-eth-fee/test-eth-txn-weights.ts @@ -2,12 +2,25 @@ import "@moonbeam-network/api-augment"; import { expect } from "chai"; -import { alith, ALITH_PRIVATE_KEY, baltathar } from "../../util/accounts"; +import { + alith, + ALITH_PRIVATE_KEY, + baltathar, + BALTATHAR_ADDRESS, + BALTATHAR_PRIVATE_KEY, + charleth, + CHARLETH_PRIVATE_KEY, +} from "../../util/accounts"; import { getCompiled } from "../../util/contracts"; import { customWeb3Request } from "../../util/providers"; import { describeDevMoonbeam, DevTestContext } from "../../util/setup-dev-tests"; -import { EXTRINSIC_GAS_LIMIT, WEIGHT_PER_GAS } from "../../util/constants"; -import { createTransaction, ALITH_TRANSACTION_TEMPLATE } from "../../util/transactions"; +import { EXTRINSIC_GAS_LIMIT, EXTRINSIC_BASE_WEIGHT, WEIGHT_PER_GAS } from "../../util/constants"; +import { + createTransaction, + createTransfer, + ALITH_TRANSACTION_TEMPLATE, +} from "../../util/transactions"; +import { isTemplateExpression } from "typescript"; // This tests an issue where pallet Ethereum in Frontier does not properly account for weight after // transaction application. Specifically, it accounts for weight before a transaction by multiplying @@ -16,7 +29,6 @@ import { createTransaction, ALITH_TRANSACTION_TEMPLATE } from "../../util/transa describeDevMoonbeam("Ethereum Weight Accounting", (context) => { it("should account for weight used", async function () { this.timeout(10000); - const { block, result } = await context.createBlock( createTransaction(context, { ...ALITH_TRANSACTION_TEMPLATE, @@ -24,6 +36,7 @@ describeDevMoonbeam("Ethereum Weight Accounting", (context) => { maxFeePerGas: 1_000_000_000, maxPriorityFeePerGas: 0, to: baltathar.address, + nonce: 0, data: null, }) ); @@ -42,4 +55,28 @@ describeDevMoonbeam("Ethereum Weight Accounting", (context) => { expect(normalWeight.toBigInt()).to.equal(EXPECTED_WEIGHT); }); + + it("should correctly refund weight from excess gas_limit supplied", async function () { + const gasAmount = Math.floor(EXTRINSIC_GAS_LIMIT * 0.8); + const tx_1 = await createTransfer(context, baltathar.address, 1, { + gas: gasAmount, + nonce: 1, + }); + const tx_2 = await createTransfer(context, charleth.address, 1, { + gas: gasAmount, + privateKey: BALTATHAR_PRIVATE_KEY, + nonce: 0, + }); + const tx_3 = await createTransfer(context, alith.address, 1, { + gas: gasAmount, + privateKey: CHARLETH_PRIVATE_KEY, + nonce: 0, + }); + + const fails = (await context.createBlock([tx_1, tx_2, tx_3])).result.filter( + (a) => !a.successful + ); + expect(fails, `Transactions ${fails.map((a) => a.hash).join(", ")} have failed to be included`) + .to.be.empty; + }); });