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/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 = [ { 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', 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); +});