From 7639a1ece45bf5d9320b603b669be4f1c0170373 Mon Sep 17 00:00:00 2001 From: crypt0grapher <7blockchains@gmail.com> Date: Tue, 10 Feb 2026 18:01:57 +0100 Subject: [PATCH 1/3] fix: convert maxFeePerGas/maxPriorityFeePerGas from tinybars to weibars Apply TINYBAR_TO_WEIBAR_COEF multiplication to EIP-1559 fee cap fields in createTransactionFromContractResult(), matching the existing gasPrice conversion pattern. Previously these fields were passed through as raw tinybars, causing unit mismatch with effectiveGasPrice and baseFeePerGas. Fixes hiero-ledger/hiero-json-rpc-relay#4901 Signed-off-by: crypt0grapher <7blockchains@gmail.com> --- .../src/lib/factories/transactionFactory.ts | 18 +++++++++-- ..._getTransactionByBlockHashAndIndex.spec.ts | 4 +++ ...etTransactionByBlockNumberAndIndex.spec.ts | 31 ++++++++++++++++++- .../lib/eth/eth_getTransactionByHash.spec.ts | 12 +++---- .../lib/factories/transactionFactory.spec.ts | 2 +- 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/relay/src/lib/factories/transactionFactory.ts b/packages/relay/src/lib/factories/transactionFactory.ts index 9e40d6ce1c..903ebe3f81 100644 --- a/packages/relay/src/lib/factories/transactionFactory.ts +++ b/packages/relay/src/lib/factories/transactionFactory.ts @@ -118,9 +118,23 @@ export const createTransactionFromContractResult = (cr: any): Transaction | null chainId: cr.chain_id === constants.EMPTY_HEX ? undefined : cr.chain_id, }; + const maxPriorityFeePerGas = + cr.max_priority_fee_per_gas === null || cr.max_priority_fee_per_gas === constants.EMPTY_HEX + ? null + : isHex(cr.max_priority_fee_per_gas) + ? numberTo0x(BigInt(cr.max_priority_fee_per_gas) * BigInt(constants.TINYBAR_TO_WEIBAR_COEF)) + : nanOrNumberTo0x(cr.max_priority_fee_per_gas); + + const maxFeePerGas = + cr.max_fee_per_gas === null || cr.max_fee_per_gas === constants.EMPTY_HEX + ? null + : isHex(cr.max_fee_per_gas) + ? numberTo0x(BigInt(cr.max_fee_per_gas) * BigInt(constants.TINYBAR_TO_WEIBAR_COEF)) + : nanOrNumberTo0x(cr.max_fee_per_gas); + return TransactionFactory.createTransactionByType(cr.type, { ...commonFields, - maxPriorityFeePerGas: cr.max_priority_fee_per_gas, - maxFeePerGas: cr.max_fee_per_gas, + maxPriorityFeePerGas, + maxFeePerGas, }); }; diff --git a/packages/relay/tests/lib/eth/eth_getTransactionByBlockHashAndIndex.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionByBlockHashAndIndex.spec.ts index a469f9bf69..d5a349b66d 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionByBlockHashAndIndex.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionByBlockHashAndIndex.spec.ts @@ -226,6 +226,10 @@ describe('@ethGetTransactionByBlockHashAndIndex using MirrorNode', async functio requestDetails, ); expect(result).to.be.an.instanceOf(Transaction1559); + if (result) { + expect((result as Transaction1559).maxFeePerGas).to.equal('0xa54f4c3c00'); + expect((result as Transaction1559).maxPriorityFeePerGas).to.equal('0xa54f4c3c00'); + } }); describe('synthetic transaction handling', function () { diff --git a/packages/relay/tests/lib/eth/eth_getTransactionByBlockNumberAndIndex.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionByBlockNumberAndIndex.spec.ts index 99dc9d67bb..6563c9ed02 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionByBlockNumberAndIndex.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionByBlockNumberAndIndex.spec.ts @@ -11,7 +11,7 @@ import { Eth } from '../../../src'; import { SDKClient } from '../../../src/lib/clients'; import type { ICacheClient } from '../../../src/lib/clients/cache/ICacheClient'; import { predefined } from '../../../src/lib/errors/JsonRpcError'; -import { Transaction } from '../../../src/lib/model'; +import { Transaction, Transaction1559 } from '../../../src/lib/model'; import HAPIService from '../../../src/lib/services/hapiService/hapiService'; import { RequestDetails } from '../../../src/lib/types'; import RelayAssertions from '../../assertions'; @@ -21,6 +21,7 @@ import { BLOCK_NUMBER_HEX, CONTRACT_ADDRESS_1, CONTRACT_HASH_1, + CONTRACT_RESULT_MOCK, CONTRACT_TIMESTAMP_1, DEFAULT_BLOCK, DEFAULT_BLOCKS_RES, @@ -273,6 +274,34 @@ describe('@ethGetTransactionByBlockNumberAndIndex using MirrorNode', async funct verifyAggregatedInfo(result); }); + it('eth_getTransactionByBlockNumberAndIndex returns 1559 transaction for type 2 with converted fee caps', async function () { + restMock.onGet(contractResultsByNumberByIndexURL(DEFAULT_BLOCK.number, DEFAULT_BLOCK.count)).reply( + 200, + JSON.stringify({ + results: [ + { + ...CONTRACT_RESULT_MOCK, + type: 2, + access_list: [], + max_fee_per_gas: '0x47', + max_priority_fee_per_gas: '0x47', + }, + ], + }), + ); + + const result = await ethImpl.getTransactionByBlockNumberAndIndex( + numberTo0x(DEFAULT_BLOCK.number), + numberTo0x(DEFAULT_BLOCK.count), + requestDetails, + ); + expect(result).to.be.an.instanceOf(Transaction1559); + if (result) { + expect((result as Transaction1559).maxFeePerGas).to.equal('0xa54f4c3c00'); + expect((result as Transaction1559).maxPriorityFeePerGas).to.equal('0xa54f4c3c00'); + } + }); + describe('synthetic transaction handling', function () { it('returns synthetic transaction when contract result is empty but logs exist', async function () { // Mock contract results returning empty (no EVM transaction) diff --git a/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts index 58de60131d..8464248dfc 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts @@ -202,8 +202,8 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi const result = await ethImpl.getTransactionByHash(DEFAULT_TX_HASH, requestDetails); RelayAssertions.assertTransaction(result, { ...DEFAULT_TRANSACTION, - maxFeePerGas: '0x55', - maxPriorityFeePerGas: '0x43', + maxFeePerGas: '0xc5e7f2b400', + maxPriorityFeePerGas: '0x9bff1cac00', }); }); @@ -220,8 +220,8 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi const result = await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); RelayAssertions.assertTransaction(result, { ...DEFAULT_TRANSACTION, - maxFeePerGas: '0x55', - maxPriorityFeePerGas: '0x43', + maxFeePerGas: '0xc5e7f2b400', + maxPriorityFeePerGas: '0x9bff1cac00', r: '0x0', s: '0x0', }); @@ -335,8 +335,8 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi const result = await ethImpl.getTransactionByHash(DEFAULT_TX_HASH, requestDetails); RelayAssertions.assertTransaction(result, { ...DEFAULT_TRANSACTION, - maxFeePerGas: '0x55', - maxPriorityFeePerGas: '0x43', + maxFeePerGas: '0xc5e7f2b400', + maxPriorityFeePerGas: '0x9bff1cac00', }); }); diff --git a/packages/relay/tests/lib/factories/transactionFactory.spec.ts b/packages/relay/tests/lib/factories/transactionFactory.spec.ts index 7e3565146e..4e50bceee6 100644 --- a/packages/relay/tests/lib/factories/transactionFactory.spec.ts +++ b/packages/relay/tests/lib/factories/transactionFactory.spec.ts @@ -211,7 +211,7 @@ describe('TransactionFactory', () => { expect(formattedResult.hash).to.equal('0xfc4ab7133197016293d2e14e8cf9c5227b07357e6385184f1cd1cb40d783cfbd'); expect(formattedResult.input).to.equal('0x08090033'); expect(formattedResult.maxPriorityFeePerGas).to.equal(expectedValues.maxPriorityFeePerGas ?? '0x0'); - expect(formattedResult.maxFeePerGas).to.equal(expectedValues.maxFeePerGas ?? '0x59'); + expect(formattedResult.maxFeePerGas).to.equal(expectedValues.maxFeePerGas ?? '0xcf38224400'); expect(formattedResult.nonce).to.equal(expectedValues.nonce ?? '0x2'); expect(formattedResult.r).to.equal( expectedValues.r ?? '0x2af9d41244c702764ed86c5b9f1a734b075b91c4d9c65e78bc584b0e35181e42', From 4be9f1c0a8666e1ef97105d1ebf04147186deac4 Mon Sep 17 00:00:00 2001 From: crypt0grapher <7blockchains@gmail.com> Date: Tue, 10 Feb 2026 19:48:05 +0100 Subject: [PATCH 2/3] fix: use block-time gas price for baseFeePerGas instead of current price baseFeePerGas in block responses was using the current network gas price at query time rather than the gas price at the block's timestamp. This caused baseFeePerGas to drift across blocks depending on when they were first queried, and resulted in inconsistent values in Blockscout's gas price oracle. Use getGasPriceInWeibars(requestDetails, blockTimestamp) to fetch the fee schedule at block creation time, matching the pattern already used in getBlockReceipts. Signed-off-by: crypt0grapher <7blockchains@gmail.com> --- .../lib/services/ethService/blockService/blockWorker.ts | 5 ++++- packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts | 1 + .../relay/tests/lib/eth/eth_getBlockByNumber.spec.ts | 9 ++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts b/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts index cbe0e50328..0aa111dc9d 100644 --- a/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts +++ b/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts @@ -318,7 +318,10 @@ export async function getBlock( const receiptsRoot: string = await getRootHash(receipts); - const gasPrice = await commonService.gasPrice(requestDetails); + // Use block-time gas price (not current) so baseFeePerGas matches effectiveGasPrice. + // Mirrors the pattern in getBlockReceipts (line 364). + const blockTimestamp = blockResponse.timestamp?.from?.split('.')[0] ?? ''; + const gasPrice = numberTo0x(await commonService.getGasPriceInWeibars(requestDetails, blockTimestamp)); return await BlockFactory.createBlock({ blockResponse, diff --git a/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts b/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts index 429586c34d..743429fbf1 100644 --- a/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts @@ -71,6 +71,7 @@ describe('@ethGetBlockByHash using MirrorNode', async function () { sdkClientStub = sinon.createStubInstance(SDKClient); getSdkClientStub = sinon.stub(hapiServiceInstance, 'getSDKClient').returns(sdkClientStub); restMock.onGet('network/fees').reply(200, JSON.stringify(modifiedNetworkFees)); + restMock.onGet(/network\/fees\?timestamp=\d+/).reply(200, JSON.stringify(modifiedNetworkFees)); restMock.onGet(ACCOUNT_WITHOUT_TRANSACTIONS).reply(200, JSON.stringify(MOCK_ACCOUNT_WITHOUT_TRANSACTIONS)); restMock .onGet(contractByEvmAddress(CONTRACT_ADDRESS_1)) diff --git a/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts b/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts index 9b81c15613..cfb6da1417 100644 --- a/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts @@ -32,6 +32,7 @@ import { BLOCK_NUMBER, BLOCK_NUMBER_HEX, BLOCK_NUMBER_WITH_SYN_TXN, + BLOCK_TIMESTAMP, BLOCK_TIMESTAMP_HEX, BLOCK_WITH_SYN_TXN, BLOCKS_LIMIT_ORDER_URL, @@ -128,6 +129,7 @@ describe('@ethGetBlockByNumber using MirrorNode', async function () { const modifiedNetworkFees = structuredClone(DEFAULT_NETWORK_FEES); modifiedNetworkFees.fees[2].gas *= 100; restMock.onGet('network/fees').reply(200, JSON.stringify(modifiedNetworkFees)); + restMock.onGet(/network\/fees\?timestamp=\d+/).reply(200, JSON.stringify(modifiedNetworkFees)); restMock.onGet(`accounts/${defaultContractResults.results[0].from}?transactions=false`).reply(200); restMock.onGet(`accounts/${defaultContractResults.results[1].from}?transactions=false`).reply(200); restMock.onGet(`accounts/${defaultContractResults.results[0].to}?transactions=false`).reply(200); @@ -444,7 +446,7 @@ describe('@ethGetBlockByNumber using MirrorNode', async function () { expect(restMock.history.get[2].url).equal( 'contracts/results/logs?timestamp=gte:1651560386.060890949×tamp=lte:1651560389.060890949&limit=100&order=asc', ); - expect(restMock.history.get[TOTAL_GET_CALLS_EXECUTED - 1].url).equal('network/fees'); + expect(restMock.history.get[TOTAL_GET_CALLS_EXECUTED - 1].url).equal(`network/fees?timestamp=${BLOCK_TIMESTAMP}`); confirmResult(result); }); @@ -479,7 +481,7 @@ describe('@ethGetBlockByNumber using MirrorNode', async function () { expect(restMock.history.get[2].url).equal( 'contracts/results/logs?timestamp=gte:1651560386.060890949×tamp=lte:1651560389.060890949&limit=100&order=asc', ); - expect(restMock.history.get[TOTAL_GET_CALLS_EXECUTED - 1].url).equal('network/fees'); + expect(restMock.history.get[TOTAL_GET_CALLS_EXECUTED - 1].url).equal(`network/fees?timestamp=${BLOCK_TIMESTAMP}`); confirmResult(result); }); @@ -494,7 +496,7 @@ describe('@ethGetBlockByNumber using MirrorNode', async function () { expect(restMock.history.get[2].url).equal( 'contracts/results/logs?timestamp=gte:1651560386.060890949×tamp=lte:1651560389.060890949&limit=100&order=asc', ); - expect(restMock.history.get[TOTAL_GET_CALLS_EXECUTED - 1].url).equal('network/fees'); + expect(restMock.history.get[TOTAL_GET_CALLS_EXECUTED - 1].url).equal(`network/fees?timestamp=${BLOCK_TIMESTAMP}`); confirmResult(result); }); @@ -584,6 +586,7 @@ describe('@ethGetBlockByNumber using MirrorNode', async function () { restMock.onGet(`blocks/${BLOCK_HASH}`).reply(200, JSON.stringify(DEFAULT_BLOCK)); restMock.onGet(CONTRACT_RESULTS_WITH_FILTER_URL).reply(200, JSON.stringify(defaultContractResults)); restMock.onGet('network/fees').reply(200, JSON.stringify(DEFAULT_NETWORK_FEES)); + restMock.onGet(/network\/fees\?timestamp=\d+/).reply(200, JSON.stringify(DEFAULT_NETWORK_FEES)); const nullEntitiedLogs = [ { From 694a6eefcdf8c7498d5452e355b064ca3811f508 Mon Sep 17 00:00:00 2001 From: crypt0grapher <7blockchains@gmail.com> Date: Tue, 10 Feb 2026 19:48:14 +0100 Subject: [PATCH 3/3] test: add Blockscout gas_prices consistency check to validation script Add Step 5 to validate-fee-caps.ts that queries the Blockscout stats API and asserts gas_prices are within 2x of eth_gasPrice and positive. Signed-off-by: crypt0grapher <7blockchains@gmail.com> --- scripts/validate-fee-caps.ts | 243 +++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 scripts/validate-fee-caps.ts diff --git a/scripts/validate-fee-caps.ts b/scripts/validate-fee-caps.ts new file mode 100644 index 0000000000..c3af8f3312 --- /dev/null +++ b/scripts/validate-fee-caps.ts @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Automated live validation for EIP-1559 fee cap unit fix (#4901). + * + * Sends a type-2 transaction on Goliath Testnet and verifies that + * maxFeePerGas and maxPriorityFeePerGas are returned in weibars + * (same unit as effectiveGasPrice), not raw tinybars. + * + * Usage: + * GOLIATH_TEST_PRIVATE_KEY= npx ts-node scripts/validate-fee-caps.ts + * + * Environment variables: + * GOLIATH_TEST_PRIVATE_KEY - Private key (hex, no 0x prefix ok) for a funded Goliath Testnet account + * GOLIATH_RPC_URL - (optional) RPC endpoint, defaults to http://104.238.187.163:30756 + */ + +import { ethers } from 'ethers'; + +const TINYBAR_TO_WEIBAR_COEF = 10_000_000_000n; +const RPC_URL = process.env.GOLIATH_RPC_URL || 'http://104.238.187.163:30756'; +const BLOCKSCOUT_STATS_URL = process.env.BLOCKSCOUT_STATS_URL || 'https://testnet.explorer.goliath.net/api/v2/stats'; +const PRIVATE_KEY = process.env.GOLIATH_TEST_PRIVATE_KEY; + +interface ValidationResult { + passed: boolean; + txHash: string; + txType: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + effectiveGasPrice: string; + maxFeePerGasDec: bigint; + maxPriorityFeePerGasDec: bigint; + effectiveGasPriceDec: bigint; + errors: string[]; +} + +async function main(): Promise { + if (!PRIVATE_KEY) { + console.error('ERROR: GOLIATH_TEST_PRIVATE_KEY env var is required'); + process.exit(1); + } + + console.log(`\n=== EIP-1559 Fee Cap Validation (fix-4901) ===`); + console.log(`RPC: ${RPC_URL}\n`); + + const provider = new ethers.JsonRpcProvider(RPC_URL); + const wallet = new ethers.Wallet(PRIVATE_KEY, provider); + const address = await wallet.getAddress(); + + console.log(`Wallet: ${address}`); + + // Check balance + const balance = await provider.getBalance(address); + console.log(`Balance: ${ethers.formatEther(balance)} XCN (${balance} wei)\n`); + + if (balance === 0n) { + console.error('ERROR: Wallet has zero balance — fund it before running validation'); + process.exit(1); + } + + // Step 1: Send a type-2 (EIP-1559) transaction — simple self-transfer of 0 value + console.log('Step 1: Sending type-2 transaction...'); + const feeData = await provider.getFeeData(); + console.log( + ` Network fee data: gasPrice=${feeData.gasPrice}, maxFeePerGas=${feeData.maxFeePerGas}, maxPriorityFeePerGas=${feeData.maxPriorityFeePerGas}`, + ); + + const tx = await wallet.sendTransaction({ + to: address, + value: 0n, + type: 2, + maxFeePerGas: feeData.maxFeePerGas ?? feeData.gasPrice, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n, + }); + + console.log(` Tx hash: ${tx.hash}`); + console.log(' Waiting for confirmation...'); + + const receipt = await tx.wait(); + if (!receipt) { + console.error('ERROR: Transaction receipt is null'); + process.exit(1); + } + console.log(` Confirmed in block ${receipt.blockNumber}, status=${receipt.status}\n`); + + // Step 2: Query via eth_getTransactionByHash (raw JSON-RPC to see exact hex values) + console.log('Step 2: Querying eth_getTransactionByHash...'); + const txResult = await provider.send('eth_getTransactionByHash', [tx.hash]); + + if (!txResult) { + console.error('ERROR: eth_getTransactionByHash returned null'); + process.exit(1); + } + + // Step 3: Query receipt for effectiveGasPrice + console.log('Step 3: Querying eth_getTransactionReceipt...'); + const receiptResult = await provider.send('eth_getTransactionReceipt', [tx.hash]); + + // Step 4: Validate + console.log('\nStep 4: Validating fee field units...\n'); + + const result: ValidationResult = { + passed: true, + txHash: tx.hash, + txType: txResult.type, + maxFeePerGas: txResult.maxFeePerGas, + maxPriorityFeePerGas: txResult.maxPriorityFeePerGas, + effectiveGasPrice: receiptResult.effectiveGasPrice, + maxFeePerGasDec: BigInt(txResult.maxFeePerGas), + maxPriorityFeePerGasDec: BigInt(txResult.maxPriorityFeePerGas), + effectiveGasPriceDec: BigInt(receiptResult.effectiveGasPrice), + errors: [], + }; + + // Print raw values + console.log(` tx.type: ${result.txType}`); + console.log(` tx.maxFeePerGas: ${result.maxFeePerGas} (${result.maxFeePerGasDec})`); + console.log(` tx.maxPriorityFeePerGas: ${result.maxPriorityFeePerGas} (${result.maxPriorityFeePerGasDec})`); + console.log(` receipt.effectiveGasPrice: ${result.effectiveGasPrice} (${result.effectiveGasPriceDec})\n`); + + // Assertion 1: Transaction must be type 2 + if (result.txType !== '0x2') { + result.errors.push(`Expected type 0x2, got ${result.txType}`); + result.passed = false; + } + + // Assertion 2: maxFeePerGas must be in weibar range (>= 1 tinybar in weibars = 10^10) + // A tinybar passthrough would be < 10^6 typically; weibars should be >= 10^10 + if (result.maxFeePerGasDec < TINYBAR_TO_WEIBAR_COEF) { + result.errors.push( + `maxFeePerGas ${result.maxFeePerGasDec} appears to be in tinybars (< ${TINYBAR_TO_WEIBAR_COEF}). Expected weibars.`, + ); + result.passed = false; + } + + // Assertion 3: maxPriorityFeePerGas must be in weibar range if non-zero + if (result.maxPriorityFeePerGasDec > 0n && result.maxPriorityFeePerGasDec < TINYBAR_TO_WEIBAR_COEF) { + result.errors.push( + `maxPriorityFeePerGas ${result.maxPriorityFeePerGasDec} appears to be in tinybars (< ${TINYBAR_TO_WEIBAR_COEF}). Expected weibars.`, + ); + result.passed = false; + } + + // Assertion 4: maxFeePerGas and effectiveGasPrice must be within 100x of each other + // (same order of magnitude = compatible units) + if (result.effectiveGasPriceDec > 0n) { + const ratio = + result.maxFeePerGasDec > result.effectiveGasPriceDec + ? result.maxFeePerGasDec / result.effectiveGasPriceDec + : result.effectiveGasPriceDec / result.maxFeePerGasDec; + + console.log(` maxFeePerGas / effectiveGasPrice ratio: ${ratio}x`); + + if (ratio > 100n) { + result.errors.push( + `maxFeePerGas (${result.maxFeePerGasDec}) and effectiveGasPrice (${result.effectiveGasPriceDec}) differ by ${ratio}x — unit mismatch detected.`, + ); + result.passed = false; + } + } + + // Assertion 5: maxFeePerGas >= effectiveGasPrice (the cap should not be below the effective price) + if (result.maxFeePerGasDec < result.effectiveGasPriceDec) { + result.errors.push( + `maxFeePerGas (${result.maxFeePerGasDec}) < effectiveGasPrice (${result.effectiveGasPriceDec}) — this is invalid for a confirmed transaction.`, + ); + result.passed = false; + } + + // Step 5: Validate Blockscout gas_prices consistency + console.log('\nStep 5: Checking Blockscout stats API...'); + try { + const statsResponse = await fetch(BLOCKSCOUT_STATS_URL); + if (!statsResponse.ok) { + console.log(` WARNING: Blockscout stats returned ${statsResponse.status} — skipping check`); + } else { + const stats = (await statsResponse.json()) as { + gas_prices?: { slow?: { price: number }; average?: { price: number }; fast?: { price: number } }; + }; + const gasPrices = stats.gas_prices; + + if (!gasPrices) { + console.log(' WARNING: gas_prices field missing from Blockscout stats — skipping check'); + } else { + const avgPrice = gasPrices.average?.price ?? gasPrices.slow?.price ?? gasPrices.fast?.price; + console.log( + ` Blockscout gas_prices: slow=${gasPrices.slow?.price}, average=${gasPrices.average?.price}, fast=${gasPrices.fast?.price} (Gwei)`, + ); + + if (avgPrice != null && avgPrice > 0) { + // Compare with eth_gasPrice (convert from wei to Gwei) + const ethGasPriceResult = await provider.send('eth_gasPrice', []); + const ethGasPriceWei = BigInt(ethGasPriceResult); + const ethGasPriceGwei = Number(ethGasPriceWei) / 1e9; + + console.log(` eth_gasPrice: ${ethGasPriceGwei.toFixed(2)} Gwei`); + + const blockscoutRatio = avgPrice > ethGasPriceGwei ? avgPrice / ethGasPriceGwei : ethGasPriceGwei / avgPrice; + console.log(` Blockscout/eth_gasPrice ratio: ${blockscoutRatio.toFixed(2)}x`); + + // Assertion 6: Blockscout gas_prices must be within 2x of eth_gasPrice + if (blockscoutRatio > 2) { + result.errors.push( + `Blockscout gas_prices.average (${avgPrice} Gwei) differs from eth_gasPrice (${ethGasPriceGwei.toFixed(2)} Gwei) by ${blockscoutRatio.toFixed(2)}x — expected within 2x.`, + ); + result.passed = false; + } + + // Assertion 7: Blockscout gas_prices must be positive (not negative from unit mismatch) + if (avgPrice < 0) { + result.errors.push(`Blockscout gas_prices.average is negative (${avgPrice} Gwei) — unit mismatch issue.`); + result.passed = false; + } + } else { + console.log(' WARNING: Blockscout gas_prices.average is null/0 — skipping comparison'); + } + } + } + } catch (err) { + console.log(` WARNING: Could not reach Blockscout stats API: ${err instanceof Error ? err.message : err}`); + } + + // Report + console.log('\n=== RESULT ===\n'); + if (result.passed) { + console.log('PASS: All fee cap fields are in weibars and consistent with effectiveGasPrice.'); + } else { + console.log('FAIL: Fee cap validation errors:'); + for (const err of result.errors) { + console.log(` - ${err}`); + } + } + + console.log(`\nTx hash for manual inspection: ${result.txHash}\n`); + + process.exit(result.passed ? 0 : 1); +} + +main().catch((err) => { + console.error('Unhandled error:', err); + process.exit(1); +});