diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8732915d9..692388a46f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -748,6 +748,12 @@ should use 4.0.1-alpha.0 for testing. - Added the error code `ERR_TX_GAS_MISMATCH` and used it inside `TransactionGasMismatchError` (#5462) - Added `SignatureError` to `web3-errors/src/errors/signature_errors.ts` (moved from `web3-eth/src/errors.ts`) (#5462) - Added the errors' classes to `web3-errors/src/errors/transaction_errors.ts` from `web3-eth/src/errors.ts` (#5462) +- Added `TransactionBlockTimeoutError` class and its error code `ERR_TX_BLOCK_TIMEOUT` (#5294) + +#### web3-eth + +- [setimmediate](https://github.com/yuzujs/setImmediate) package to polyfill [setImmediate](https://nodejs.org/api/timers.html#setimmediatecallback-args) for browsers (#5450) +- Implemented the logic for `transactionBlockTimeout` (#5294) #### web3-eth-abi @@ -759,6 +765,10 @@ should use 4.0.1-alpha.0 for testing. - Decoding error data, using Error ABI if available, according to EIP-838. (#5434) - The class `Web3ContractError` is moved from this package to `web3-error`. (#5434) +#### web3-utils + +- Added and exported three reusable utility functions: `pollTillDefined`, `rejectIfTimeout` and `rejectIfConditionAtInterval` which are useful when dealing with promises that involves polling, rejecting after timeout or rejecting if a condition was met when calling repeatably at every time intervals. + ### Changed #### web3-error diff --git a/packages/web3-errors/CHANGELOG.md b/packages/web3-errors/CHANGELOG.md index 6af5c8bd691..55f9174207d 100644 --- a/packages/web3-errors/CHANGELOG.md +++ b/packages/web3-errors/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the error code `ERR_TX_GAS_MISMATCH` and used it inside `TransactionGasMismatchError` (#5462) - Added `SignatureError` to `web3-errors/src/errors/signature_errors.ts` (moved from `web3-eth/src/errors.ts`) (#5462) - Added the errors' classes to `web3-errors/src/errors/transaction_errors.ts` from `web3-eth/src/errors.ts` (#5462) +- Added `TransactionBlockTimeoutError` class and its error code `ERR_TX_BLOCK_TIMEOUT` (#5294) ### Changed diff --git a/packages/web3-errors/src/error_codes.ts b/packages/web3-errors/src/error_codes.ts index d530ba0e507..6df62ce9715 100644 --- a/packages/web3-errors/src/error_codes.ts +++ b/packages/web3-errors/src/error_codes.ts @@ -76,6 +76,7 @@ export const ERR_TX_LOCAL_WALLET_NOT_AVAILABLE = 429; export const ERR_TX_NOT_FOUND = 430; export const ERR_TX_SEND_TIMEOUT = 431; +export const ERR_TX_BLOCK_TIMEOUT = 432; export const ERR_TX_SIGNING = 433; export const ERR_TX_GAS_MISMATCH = 434; diff --git a/packages/web3-errors/src/errors/transaction_errors.ts b/packages/web3-errors/src/errors/transaction_errors.ts index 27e99ce81f7..fe6584825e5 100644 --- a/packages/web3-errors/src/errors/transaction_errors.ts +++ b/packages/web3-errors/src/errors/transaction_errors.ts @@ -21,6 +21,7 @@ import { Bytes, HexString, Numbers, TransactionReceipt } from 'web3-types'; import { ERR_RAW_TX_UNDEFINED, ERR_TX, + ERR_TX_BLOCK_TIMEOUT, ERR_TX_CONTRACT_NOT_STORED, ERR_TX_CHAIN_ID_MISMATCH, ERR_TX_DATA_AND_INPUT, @@ -40,19 +41,19 @@ import { ERR_TX_MISSING_CUSTOM_CHAIN_ID, ERR_TX_MISSING_GAS, ERR_TX_NO_CONTRACT_ADDRESS, + ERR_TX_NOT_FOUND, ERR_TX_OUT_OF_GAS, + ERR_TX_POLLING_TIMEOUT, + ERR_TX_RECEIPT_MISSING_BLOCK_NUMBER, + ERR_TX_RECEIPT_MISSING_OR_BLOCKHASH_NULL, ERR_TX_REVERT_INSTRUCTION, ERR_TX_REVERT_TRANSACTION, ERR_TX_REVERT_WITHOUT_REASON, - ERR_TX_NOT_FOUND, ERR_TX_SEND_TIMEOUT, ERR_TX_SIGNING, ERR_TX_UNABLE_TO_POPULATE_NONCE, ERR_TX_UNSUPPORTED_EIP_1559, ERR_TX_UNSUPPORTED_TYPE, - ERR_TX_POLLING_TIMEOUT, - ERR_TX_RECEIPT_MISSING_OR_BLOCKHASH_NULL, - ERR_TX_RECEIPT_MISSING_BLOCK_NUMBER, } from '../error_codes'; import { InvalidValueError, Web3Error } from '../web3_error_base'; @@ -157,6 +158,7 @@ export class TransactionNotFound extends TransactionError { this.code = ERR_TX_NOT_FOUND; } } + export class InvalidTransactionWithSender extends InvalidValueError { public code = ERR_TX_INVALID_SENDER; @@ -410,6 +412,22 @@ export class TransactionPollingTimeoutError extends Web3Error { } } +export class TransactionBlockTimeoutError extends Web3Error { + public code = ERR_TX_BLOCK_TIMEOUT; + + public constructor(value: { + starterBlockNumber: number; + numberOfBlocks: number; + transactionHash?: Bytes; + }) { + super( + `Transaction started at ${value.starterBlockNumber} but was not mined within ${ + value.numberOfBlocks + } blocks. ${transactionTimeoutHint(value.transactionHash)}`, + ); + } +} + export class TransactionMissingReceiptOrBlockHashError extends InvalidValueError { public code = ERR_TX_RECEIPT_MISSING_OR_BLOCKHASH_NULL; diff --git a/packages/web3-eth-personal/test/integration/personal.test.ts b/packages/web3-eth-personal/test/integration/personal.test.ts index f3aa2d513a9..6021b13284e 100644 --- a/packages/web3-eth-personal/test/integration/personal.test.ts +++ b/packages/web3-eth-personal/test/integration/personal.test.ts @@ -24,7 +24,7 @@ import { createAccount, createNewAccount, createTempAccount, - closeOpenConnection + closeOpenConnection, } from '../fixtures/system_test_utils'; describe('personal integration tests', () => { diff --git a/packages/web3-eth/CHANGELOG.md b/packages/web3-eth/CHANGELOG.md index de3955849b0..14eeba2d4c2 100644 --- a/packages/web3-eth/CHANGELOG.md +++ b/packages/web3-eth/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [setimmediate](https://github.com/yuzujs/setImmediate) package to polyfill [setImmediate](https://nodejs.org/api/timers.html#setimmediatecallback-args) for browsers (#5450) +- Implemented the logic for `transactionBlockTimeout` (#5294) ### Removed diff --git a/packages/web3-eth/src/rpc_method_wrappers.ts b/packages/web3-eth/src/rpc_method_wrappers.ts index 1371b4638d2..2fb4e81e8a5 100644 --- a/packages/web3-eth/src/rpc_method_wrappers.ts +++ b/packages/web3-eth/src/rpc_method_wrappers.ts @@ -41,21 +41,11 @@ import { TransactionWithLocalWalletIndex, } from 'web3-types'; import { Web3Context, Web3PromiEvent } from 'web3-core'; -import { - ETH_DATA_FORMAT, - FormatType, - DataFormat, - DEFAULT_RETURN_FORMAT, - format, - waitWithTimeout, -} from 'web3-utils'; +import { ETH_DATA_FORMAT, FormatType, DataFormat, DEFAULT_RETURN_FORMAT, format } from 'web3-utils'; import { isBlockTag, isBytes, isNullish, isString } from 'web3-validator'; -import { - TransactionError, - TransactionRevertError, - SignatureError, - TransactionSendTimeoutError, -} from 'web3-errors'; +import { SignatureError, TransactionError, TransactionRevertError } from 'web3-errors'; + +import { decodeSignedTransaction } from './utils/decode_signed_transaction'; import * as rpcMethods from './rpc_methods'; import { accountSchema, @@ -77,11 +67,12 @@ import { formatTransaction } from './utils/format_transaction'; // eslint-disable-next-line import/no-cycle import { getTransactionGasPricing } from './utils/get_transaction_gas_pricing'; // eslint-disable-next-line import/no-cycle +import { trySendTransaction } from './utils/try_send_transaction'; +// eslint-disable-next-line import/no-cycle import { waitForTransactionReceipt } from './utils/wait_for_transaction_receipt'; import { watchTransactionForConfirmations } from './utils/watch_transaction_for_confirmations'; import { Web3EthExecutionAPI } from './web3_eth_execution_api'; import { NUMBER_DATA_FORMAT } from './constants'; -import { decodeSignedTransaction } from './utils/decode_signed_transaction'; /** * @@ -1119,27 +1110,23 @@ export function sendTransaction< transactionFormatted as Record, ); - transactionHash = await waitWithTimeout( - rpcMethods.sendRawTransaction( - web3Context.requestManager, - signedTransaction.rawTransaction, - ), - web3Context.transactionSendTimeout, - new TransactionSendTimeoutError({ - numberOfSeconds: web3Context.transactionSendTimeout / 1000, - transactionHash: signedTransaction.transactionHash, - }), + transactionHash = await trySendTransaction( + web3Context, + async (): Promise => + rpcMethods.sendRawTransaction( + web3Context.requestManager, + signedTransaction.rawTransaction, + ), + signedTransaction.transactionHash, ); } else { - transactionHash = await waitWithTimeout( - rpcMethods.sendTransaction( - web3Context.requestManager, - transactionFormatted as Partial, - ), - web3Context.transactionSendTimeout, - new TransactionSendTimeoutError({ - numberOfSeconds: web3Context.transactionSendTimeout / 1000, - }), + transactionHash = await trySendTransaction( + web3Context, + async (): Promise => + rpcMethods.sendTransaction( + web3Context.requestManager, + transactionFormatted as Partial, + ), ); } @@ -1190,6 +1177,7 @@ export function sendTransaction< ); } reject(transactionReceiptFormatted as unknown as ResolveType); + return; } else { resolve(transactionReceiptFormatted as unknown as ResolveType); } @@ -1340,9 +1328,13 @@ export function sendSignedTransaction< // await getRevertReason(web3Context, transaction, returnFormat); // } - const transactionHash = await rpcMethods.sendRawTransaction( - web3Context.requestManager, - signedTransactionFormattedHex, + const transactionHash = await trySendTransaction( + web3Context, + async (): Promise => + rpcMethods.sendRawTransaction( + web3Context.requestManager, + signedTransactionFormattedHex, + ), ); if (promiEvent.listenerCount('sent') > 0) { @@ -1392,6 +1384,7 @@ export function sendSignedTransaction< ); } reject(transactionReceiptFormatted as unknown as ResolveType); + return; } else { resolve(transactionReceiptFormatted as unknown as ResolveType); } diff --git a/packages/web3-eth/src/utils/reject_if_block_timeout.ts b/packages/web3-eth/src/utils/reject_if_block_timeout.ts new file mode 100644 index 00000000000..6710a0f87fd --- /dev/null +++ b/packages/web3-eth/src/utils/reject_if_block_timeout.ts @@ -0,0 +1,53 @@ +/* +This file is part of web3.js. + +web3.js is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +web3.js is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with web3.js. If not, see . +*/ +import { EthExecutionAPI, Bytes } from 'web3-types'; +import { Web3Context } from 'web3-core'; +import { rejectIfConditionAtInterval } from 'web3-utils'; + +import { TransactionBlockTimeoutError } from 'web3-errors'; +import { NUMBER_DATA_FORMAT } from '../constants'; +// eslint-disable-next-line import/no-cycle +import { getBlockNumber } from '../rpc_method_wrappers'; + +/* TODO: After merge, there will be constant block mining time (exactly 12 second each block, except slot missed that currently happens in <1% of slots. ) so we can optimize following function +for POS NWs, we can skip checking getBlockNumber(); after interval and calculate only based on time that certain num of blocked are mined after that for internal double check, can do one getBlockNumber() call and timeout. +*/ +export function rejectIfBlockTimeout( + web3Context: Web3Context, + starterBlockNumber: number, + interval: number, + transactionHash?: Bytes, +): [NodeJS.Timer, Promise] { + return rejectIfConditionAtInterval(async () => { + let lastBlockNumber; + try { + lastBlockNumber = await getBlockNumber(web3Context, NUMBER_DATA_FORMAT); + } catch (error) { + console.warn('An error happen while trying to get the block number', error); + return undefined; + } + const numberOfBlocks = lastBlockNumber - starterBlockNumber; + if (numberOfBlocks >= web3Context.transactionBlockTimeout) { + return new TransactionBlockTimeoutError({ + starterBlockNumber, + numberOfBlocks, + transactionHash, + }); + } + return undefined; + }, interval); +} diff --git a/packages/web3-eth/src/utils/try_send_transaction.ts b/packages/web3-eth/src/utils/try_send_transaction.ts new file mode 100644 index 00000000000..fd78f18b2a0 --- /dev/null +++ b/packages/web3-eth/src/utils/try_send_transaction.ts @@ -0,0 +1,64 @@ +/* +This file is part of web3.js. + +web3.js is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +web3.js is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with web3.js. If not, see . +*/ +import { Web3Context } from 'web3-core'; +import { EthExecutionAPI, Bytes } from 'web3-types'; +import { AsyncFunction, rejectIfTimeout } from 'web3-utils'; + +import { TransactionSendTimeoutError } from 'web3-errors'; +import { NUMBER_DATA_FORMAT } from '../constants'; +// eslint-disable-next-line import/no-cycle +import { rejectIfBlockTimeout } from './reject_if_block_timeout'; +// eslint-disable-next-line import/no-cycle +import { getBlockNumber } from '../rpc_method_wrappers'; + +/** + * An internal function to send a transaction or throws if sending did not finish during the timeout during the blocks-timeout. + * @param web3Context the context to read the configurations from + * @param sendTransactionFunc the function that will send the transaction (could be sendTransaction or sendRawTransaction) + * @param transactionHash to be used inside the exception message if there will be any exceptions. + * @returns the Promise returned by the `sendTransactionFunc`. + */ +export async function trySendTransaction( + web3Context: Web3Context, + sendTransactionFunc: AsyncFunction, + transactionHash?: Bytes, +): Promise { + const pollingInterval = web3Context.transactionPollingInterval; + + const [timeoutId, rejectOnTimeout] = rejectIfTimeout( + web3Context.transactionSendTimeout, + new TransactionSendTimeoutError({ + numberOfSeconds: web3Context.transactionSendTimeout / 1000, + transactionHash, + }), + ); + + const starterBlockNumber = await getBlockNumber(web3Context, NUMBER_DATA_FORMAT); + const [intervalId, rejectOnBlockTimeout] = rejectIfBlockTimeout( + web3Context, + starterBlockNumber, + pollingInterval, + transactionHash, + ); + + try { + return await Promise.race([sendTransactionFunc(), rejectOnTimeout, rejectOnBlockTimeout]); + } finally { + clearTimeout(timeoutId); + clearInterval(intervalId); + } +} diff --git a/packages/web3-eth/src/utils/wait_for_transaction_receipt.ts b/packages/web3-eth/src/utils/wait_for_transaction_receipt.ts index 11cf2c90cd0..4bf05762fbb 100644 --- a/packages/web3-eth/src/utils/wait_for_transaction_receipt.ts +++ b/packages/web3-eth/src/utils/wait_for_transaction_receipt.ts @@ -15,13 +15,16 @@ You should have received a copy of the GNU Lesser General Public License along with web3.js. If not, see . */ -import { EthExecutionAPI, Bytes, TransactionReceipt } from 'web3-types'; import { Web3Context } from 'web3-core'; import { TransactionPollingTimeoutError } from 'web3-errors'; -import { DataFormat, isNullish, waitWithTimeout } from 'web3-utils'; +import { EthExecutionAPI, Bytes, TransactionReceipt } from 'web3-types'; +import { DataFormat, rejectIfTimeout, pollTillDefined } from 'web3-utils'; +import { NUMBER_DATA_FORMAT } from '../constants'; // eslint-disable-next-line import/no-cycle -import { getTransactionReceipt } from '../rpc_method_wrappers'; +import { rejectIfBlockTimeout } from './reject_if_block_timeout'; +// eslint-disable-next-line import/no-cycle +import { getBlockNumber, getTransactionReceipt } from '../rpc_method_wrappers'; export async function waitForTransactionReceipt( web3Context: Web3Context, @@ -31,51 +34,39 @@ export async function waitForTransactionReceipt const pollingInterval = web3Context.transactionReceiptPollingInterval ?? web3Context.transactionPollingInterval; - const awaitableTransactionReceipt: Promise = waitWithTimeout( - getTransactionReceipt(web3Context, transactionHash, returnFormat), - pollingInterval, - ); - - let intervalId: NodeJS.Timer | undefined; - const polledTransactionReceipt = new Promise((resolve, reject) => { - let transactionPollingDuration = 0; - intervalId = setInterval(() => { - (async () => { - transactionPollingDuration += pollingInterval; - - if (transactionPollingDuration >= web3Context.transactionPollingTimeout) { - clearInterval(intervalId); - reject( - new TransactionPollingTimeoutError({ - numberOfSeconds: web3Context.transactionPollingTimeout / 1000, - transactionHash, - }), - ); - return; - } + const awaitableTransactionReceipt = pollTillDefined(async () => { + try { + return getTransactionReceipt(web3Context, transactionHash, returnFormat); + } catch (error) { + console.warn('An error happen while trying to get the transaction receipt', error); + return undefined; + } + }, pollingInterval); - const transactionReceipt: TransactionReceipt | undefined = await waitWithTimeout( - getTransactionReceipt(web3Context, transactionHash, returnFormat), - pollingInterval, - ); + const [timeoutId, rejectOnTimeout] = rejectIfTimeout( + web3Context.transactionPollingTimeout, + new TransactionPollingTimeoutError({ + numberOfSeconds: web3Context.transactionPollingTimeout / 1000, + transactionHash, + }), + ); - if (!isNullish(transactionReceipt)) { - clearInterval(intervalId); - resolve(transactionReceipt); - } - })() as unknown; - }, pollingInterval); - }); + const starterBlockNumber = await getBlockNumber(web3Context, NUMBER_DATA_FORMAT); + const [intervalId, rejectOnBlockTimeout] = rejectIfBlockTimeout( + web3Context, + starterBlockNumber, + pollingInterval, + transactionHash, + ); - // If the first call to ´getTransactionReceipt´ got the Transaction Receipt, return it - const transactionReceipt = await awaitableTransactionReceipt; - if (!isNullish(transactionReceipt)) { - if (intervalId) { - clearInterval(intervalId); - } - return transactionReceipt; + try { + return await Promise.race([ + awaitableTransactionReceipt, + rejectOnTimeout, + rejectOnBlockTimeout, + ]); + } finally { + clearTimeout(timeoutId); + clearInterval(intervalId); } - - // Otherwise, try getting the Transaction Receipt by polling - return polledTransactionReceipt; } diff --git a/packages/web3-eth/test/integration/defaults.test.ts b/packages/web3-eth/test/integration/defaults.test.ts index 539e2a917b6..7bc5217fa1e 100644 --- a/packages/web3-eth/test/integration/defaults.test.ts +++ b/packages/web3-eth/test/integration/defaults.test.ts @@ -19,7 +19,11 @@ import { Contract } from 'web3-eth-contract'; import { hexToNumber, numberToHex, DEFAULT_RETURN_FORMAT } from 'web3-utils'; import { TransactionBuilder, TransactionTypeParser, Web3Context, Web3PromiEvent } from 'web3-core'; import { TransactionReceipt, Web3BaseProvider } from 'web3-types'; -import { TransactionPollingTimeoutError, TransactionSendTimeoutError } from 'web3-errors'; +import { + TransactionBlockTimeoutError, + TransactionPollingTimeoutError, + TransactionSendTimeoutError, +} from 'web3-errors'; import { prepareTransactionForSigning, SendTransactionEvents, @@ -47,6 +51,8 @@ import { detectTransactionType } from '../../dist'; import { getTransactionGasPricing } from '../../src/utils/get_transaction_gas_pricing'; import { Resolve, sendFewTxes } from './helper'; +const MAX_32_SIGNED_INTEGER = 2147483647; + describe('defaults', () => { let web3Eth: Web3Eth; let eth2: Web3Eth; @@ -478,14 +484,14 @@ describe('defaults', () => { // Cause the events to take a long time (more than blockHeaderTimeout), // to ensure that polling of new blocks works in such cases. - // I will cause the providers that supports subscription (like WebSocket) + // This will cause the providers that supports subscription (like WebSocket) // to never return data through listening to new events - // let pr = new Promise(res => setTimeout(res, 5000)); // eslint-disable-next-line @typescript-eslint/no-misused-promises (tempEth.provider as Web3BaseProvider>).on = async () => { - // eslint-disable-next-line no-promise-executor-return - await new Promise(res => setTimeout(res, 5000)); + await new Promise(res => { + setTimeout(res, 1000000); + }); }; // Make the test run faster by casing the polling to start after 1 second @@ -498,9 +504,9 @@ describe('defaults', () => { TransactionReceipt, SendTransactionEvents > = tempEth.sendTransaction({ + from, to, value, - from, }); const confirmationPromise = new Promise((resolve: (status: bigint) => void) => { @@ -519,26 +525,21 @@ describe('defaults', () => { if (confirmations >= 2) { sentTx.removeAllListeners(); resolve(status); + } else { + // Send a transaction to cause dev providers creating new blocks to fire the 'confirmation' event again. + await tempEth.sendTransaction({ + from, + to, + value, + }); } }, ); }); - - // To cause the development node (like Ganache) to generate new block for the new transaction - // When another block is generated, the pervious transaction would be able to have 2 confirmations - // eslint-disable-next-line no-promise-executor-return - await new Promise(resolve => setTimeout(resolve, 1000)); - const tx = tempEth.sendTransaction({ - to, - value, - from, - }); - await tx; + await sentTx; // Ensure the promise the get the confirmations resolves with no error - const status = await confirmationPromise; - expect(status).toBe(BigInt(1)); }); @@ -585,6 +586,70 @@ describe('defaults', () => { } }); + it('should fail if transaction was not mined within `transactionBlockTimeout` blocks', async () => { + const eth = new Web3Eth(clientUrl); + const tempAcc2 = await createTempAccount(); + + // Make the test run faster by casing the polling to start after 2 blocks + eth.transactionBlockTimeout = 2; + // Prevent transaction from stucking for a long time if the provider (like Ganache v7.4.0) + // does not respond, when raising the nonce + eth.transactionSendTimeout = MAX_32_SIGNED_INTEGER; + // Increase other timeouts + eth.transactionPollingTimeout = MAX_32_SIGNED_INTEGER; + + const from = tempAcc2.address; + const to = tempAcc.address; + const value = `0x0`; + + // Setting a high `nonce` when sending a transaction, to cause the RPC call to stuck at the Node + const sentTx: Web3PromiEvent< + TransactionReceipt, + SendTransactionEvents + > = eth.sendTransaction({ + to, + value, + from, + // The previous test has the nonce set to Number.MAX_SAFE_INTEGER. + // So, just decrease 1 from it here to not fall into another error. + nonce: Number.MAX_SAFE_INTEGER - 1, + }); + + // Some providers (mostly used for development) will make blocks only when there are new transactions + // So, send 2 transactions because in this test `transactionBlockTimeout = 2`. And do nothing if an error happens. + setTimeout(() => { + (async () => { + try { + await eth.sendTransaction({ + from: tempAcc.address, + to: tempAcc2.address, + value, + }); + } catch (error) { + // Nothing needed to be done. + } + try { + await eth.sendTransaction({ + from: tempAcc.address, + to: tempAcc2.address, + value, + }); + } catch (error) { + // Nothing needed to be done. + } + })() as unknown; + }, 100); + + try { + await sentTx; + } catch (error) { + // eslint-disable-next-line jest/no-conditional-expect + expect(error).toBeInstanceOf(TransactionBlockTimeoutError); + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/was not mined within [0-9]+ blocks/); + } + }); + it('maxListenersWarningThreshold', () => { // default expect(web3Eth.maxListenersWarningThreshold).toBe(100); diff --git a/packages/web3-eth/test/integration/helper.ts b/packages/web3-eth/test/integration/helper.ts index 768f1fd4309..a9aab702f00 100644 --- a/packages/web3-eth/test/integration/helper.ts +++ b/packages/web3-eth/test/integration/helper.ts @@ -38,6 +38,10 @@ export const sendFewTxes = async ({ }: SendFewTxParams): Promise => { const res: TransactionReceipt[] = []; for (let i = 0; i < times; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => { + setTimeout(resolve, 500); + }); const tx: Web3PromiEvent< TransactionReceipt, SendTransactionEvents diff --git a/packages/web3-eth/test/integration/rpc.test.ts b/packages/web3-eth/test/integration/rpc.test.ts index 3402b84593b..572b07dc14a 100644 --- a/packages/web3-eth/test/integration/rpc.test.ts +++ b/packages/web3-eth/test/integration/rpc.test.ts @@ -16,7 +16,6 @@ along with web3.js. If not, see . */ import { TransactionReceipt, TransactionInfo } from 'web3-types'; -import WebSocketProvider from 'web3-providers-ws'; // eslint-disable-next-line import/no-extraneous-dependencies import { Contract, decodeEventABI } from 'web3-eth-contract'; import { hexToNumber, hexToString, numberToHex, FMT_BYTES, FMT_NUMBER } from 'web3-utils'; @@ -28,12 +27,12 @@ import IpcProvider from 'web3-providers-ipc'; import { Web3Eth } from '../../src'; import { + closeOpenConnection, getSystemTestBackend, getSystemTestProvider, createNewAccount, itIf, isIpc, - isWs, createTempAccount, } from '../fixtures/system_test_utils'; import { BasicAbi, BasicBytecode } from '../shared_fixtures/build/Basic'; @@ -82,11 +81,9 @@ describe('rpc', () => { } }); - afterAll(() => { - if (isWs) { - (web3Eth.provider as WebSocketProvider).disconnect(); - (contract.provider as WebSocketProvider).disconnect(); - } + afterAll(async () => { + await closeOpenConnection(web3Eth); + await closeOpenConnection(contract); }); describe('methods', () => { diff --git a/packages/web3-eth/test/integration/watch_transaction_polling.test.ts b/packages/web3-eth/test/integration/watch_transaction_polling.test.ts index 5a75a204184..f61c41fe4e7 100644 --- a/packages/web3-eth/test/integration/watch_transaction_polling.test.ts +++ b/packages/web3-eth/test/integration/watch_transaction_polling.test.ts @@ -18,7 +18,6 @@ import { DEFAULT_RETURN_FORMAT } from 'web3-utils'; import { Web3PromiEvent } from 'web3-core'; import { TransactionReceipt } from 'web3-types'; import { Web3Eth, SendTransactionEvents } from '../../src'; -import { sendFewTxes } from './helper'; import { closeOpenConnection, @@ -67,17 +66,24 @@ describeIf(isHttp || isIpc)('watch polling transaction', () => { value, from, }); - let shouldBe = 1; const confirmationPromise = new Promise((resolve: Resolve) => { // Tx promise is handled separately // eslint-disable-next-line no-void - void sentTx.on('confirmation', ({ confirmations }) => { - expect(Number(confirmations)).toBeGreaterThanOrEqual(shouldBe); - shouldBe += 1; - if (shouldBe >= waitConfirmations) { - resolve(); - } - }); + void sentTx.on( + 'confirmation', + async ({ confirmations }: { confirmations: bigint }) => { + if (confirmations >= waitConfirmations) { + resolve(); + } else { + // Send a transaction to cause dev providers creating new blocks to fire the 'confirmation' event again. + await web3Eth.sendTransaction({ + to, + value, + from, + }); + } + }, + ); }); await new Promise((resolve: Resolve) => { // Tx promise is handled separately @@ -89,8 +95,8 @@ describeIf(isHttp || isIpc)('watch polling transaction', () => { }); await sentTx; - await sendFewTxes({ web3Eth, from, to, value, times: waitConfirmations }); await confirmationPromise; + sentTx.removeAllListeners(); }); }); }); diff --git a/packages/web3-eth/test/integration/web3_eth/send_signed_transaction.test.ts b/packages/web3-eth/test/integration/web3_eth/send_signed_transaction.test.ts index 01a490a3048..894966391b9 100644 --- a/packages/web3-eth/test/integration/web3_eth/send_signed_transaction.test.ts +++ b/packages/web3-eth/test/integration/web3_eth/send_signed_transaction.test.ts @@ -290,8 +290,7 @@ describe('Web3Eth.sendSignedTransaction', () => { gas: '0x5208', }); - // TODO: Debug why the assertions are not being called - // expect.assertions(1); + expect.assertions(1); }); }); }); diff --git a/packages/web3-eth/test/unit/rpc_method_wrappers/send_signed_transaction.test.ts b/packages/web3-eth/test/unit/rpc_method_wrappers/send_signed_transaction.test.ts index ceb15cb4fb8..28a746dc34b 100644 --- a/packages/web3-eth/test/unit/rpc_method_wrappers/send_signed_transaction.test.ts +++ b/packages/web3-eth/test/unit/rpc_method_wrappers/send_signed_transaction.test.ts @@ -30,6 +30,7 @@ import { import { transactionReceiptSchema } from '../../../src/schemas'; jest.mock('../../../src/rpc_methods'); +jest.mock('../../../src/utils/wait_for_transaction_receipt'); jest.mock('../../../src/utils/watch_transaction_for_confirmations'); describe('sendTransaction', () => { @@ -47,9 +48,9 @@ describe('sendTransaction', () => { it.each(testData)( `sending event should emit with inputSignedTransaction\n ${testMessage}`, async (_, inputSignedTransaction) => { - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); const inputSignedTransactionFormatted = format( { eth: 'bytes' }, @@ -71,9 +72,9 @@ describe('sendTransaction', () => { it.each(testData)( `should call rpcMethods.sendRawTransaction with expected parameters\n ${testMessage}`, async (_, inputSignedTransaction) => { - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); const inputSignedTransactionFormatted = format( { eth: 'bytes' }, @@ -91,9 +92,9 @@ describe('sendTransaction', () => { it.each(testData)( `sent event should emit with inputSignedTransaction\n ${testMessage}`, async (_, inputSignedTransaction) => { - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); const inputSignedTransactionFormatted = format( { eth: 'bytes' }, @@ -116,9 +117,9 @@ describe('sendTransaction', () => { it.each(testData)( `transactionHash event should emit with inputSignedTransaction\n ${testMessage}`, async (_, inputSignedTransaction) => { - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); (rpcMethods.sendRawTransaction as jest.Mock).mockResolvedValueOnce( expectedTransactionHash, @@ -137,19 +138,20 @@ describe('sendTransaction', () => { ); it.each(testData)( - `should call rpcMethods.getTransactionReceipt with expected parameters\n ${testMessage}`, + `should call WaitForTransactionReceipt.waitForTransactionReceipt with expected parameters\n ${testMessage}`, async (_, inputSignedTransaction) => { (rpcMethods.sendRawTransaction as jest.Mock).mockResolvedValueOnce( expectedTransactionHash, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); await sendSignedTransaction(web3Context, inputSignedTransaction, DEFAULT_RETURN_FORMAT); - expect(rpcMethods.getTransactionReceipt).toHaveBeenCalledWith( - web3Context.requestManager, + expect(WaitForTransactionReceipt.waitForTransactionReceipt).toHaveBeenCalledWith( + web3Context, expectedTransactionHash, + DEFAULT_RETURN_FORMAT, ); }, ); @@ -184,9 +186,9 @@ describe('sendTransaction', () => { DEFAULT_RETURN_FORMAT, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); await sendSignedTransaction( web3Context, @@ -209,9 +211,9 @@ describe('sendTransaction', () => { DEFAULT_RETURN_FORMAT, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); expect( await sendSignedTransaction( web3Context, @@ -238,9 +240,9 @@ describe('sendTransaction', () => { (rpcMethods.sendRawTransaction as jest.Mock).mockResolvedValueOnce( expectedTransactionHash, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); const promiEvent = sendSignedTransaction( web3Context, @@ -250,9 +252,10 @@ describe('sendTransaction', () => { await promiEvent; - expect(rpcMethods.getTransactionReceipt).toHaveBeenCalledWith( - web3Context.requestManager, + expect(WaitForTransactionReceipt.waitForTransactionReceipt).toHaveBeenCalledWith( + web3Context, expectedTransactionHash, + DEFAULT_RETURN_FORMAT, ); expect(watchTransactionForConfirmationsSpy).toHaveBeenCalledWith( web3Context, diff --git a/packages/web3-eth/test/unit/rpc_method_wrappers/send_transaction.test.ts b/packages/web3-eth/test/unit/rpc_method_wrappers/send_transaction.test.ts index f3620ed781c..038297ba233 100644 --- a/packages/web3-eth/test/unit/rpc_method_wrappers/send_transaction.test.ts +++ b/packages/web3-eth/test/unit/rpc_method_wrappers/send_transaction.test.ts @@ -33,6 +33,7 @@ import { import { transactionReceiptSchema } from '../../../src/schemas'; jest.mock('../../../src/rpc_methods'); +jest.mock('../../../src/utils/wait_for_transaction_receipt'); jest.mock('../../../src/utils/watch_transaction_for_confirmations'); describe('sendTransaction', () => { @@ -54,7 +55,7 @@ describe('sendTransaction', () => { GetTransactionGasPricing, 'getTransactionGasPricing', ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValue( + (WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock).mockResolvedValue( expectedTransactionReceipt, ); await sendTransaction( @@ -81,7 +82,7 @@ describe('sendTransaction', () => { `sending event should emit with formattedTransaction\n ${testMessage}`, async (_, inputTransaction, sendTransactionOptions) => { const formattedTransaction = formatTransaction(inputTransaction, ETH_DATA_FORMAT); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValue( + (WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock).mockResolvedValue( expectedTransactionReceipt, ); await sendTransaction( @@ -101,7 +102,7 @@ describe('sendTransaction', () => { `should call rpcMethods.sendTransaction with expected parameters\n ${testMessage}`, async (_, inputTransaction, sendTransactionOptions) => { const formattedTransaction = formatTransaction(inputTransaction, ETH_DATA_FORMAT); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValue( + (WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock).mockResolvedValue( expectedTransactionReceipt, ); await sendTransaction( @@ -121,7 +122,7 @@ describe('sendTransaction', () => { `sent event should emit with formattedTransaction\n ${testMessage}`, async (_, inputTransaction, sendTransactionOptions) => { const formattedTransaction = formatTransaction(inputTransaction, ETH_DATA_FORMAT); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValue( + (WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock).mockResolvedValue( expectedTransactionReceipt, ); @@ -144,7 +145,7 @@ describe('sendTransaction', () => { (rpcMethods.sendTransaction as jest.Mock).mockResolvedValueOnce( expectedTransactionHash, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValue( + (WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock).mockResolvedValue( expectedTransactionReceipt, ); @@ -162,12 +163,12 @@ describe('sendTransaction', () => { ); it.each(testData)( - `should call rpcMethods.getTransactionReceipt with expected parameters\n ${testMessage}`, + `should call WaitForTransactionReceipt.waitForTransactionReceipt with expected parameters\n ${testMessage}`, async (_, inputTransaction, sendTransactionOptions) => { (rpcMethods.sendTransaction as jest.Mock).mockResolvedValueOnce( expectedTransactionHash, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValue( + (WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock).mockResolvedValue( expectedTransactionReceipt, ); @@ -177,9 +178,10 @@ describe('sendTransaction', () => { DEFAULT_RETURN_FORMAT, sendTransactionOptions, ); - expect(rpcMethods.getTransactionReceipt).toHaveBeenCalledWith( - web3Context.requestManager, + expect(WaitForTransactionReceipt.waitForTransactionReceipt).toHaveBeenCalledWith( + web3Context, expectedTransactionHash, + DEFAULT_RETURN_FORMAT, ); }, ); @@ -218,9 +220,9 @@ describe('sendTransaction', () => { expectedTransactionReceipt, DEFAULT_RETURN_FORMAT, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - formattedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(formattedTransactionReceipt); await sendTransaction( web3Context, @@ -243,9 +245,9 @@ describe('sendTransaction', () => { expectedTransactionReceipt, DEFAULT_RETURN_FORMAT, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - formattedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(formattedTransactionReceipt); expect( await sendTransaction( web3Context, @@ -273,9 +275,9 @@ describe('sendTransaction', () => { (rpcMethods.sendTransaction as jest.Mock).mockResolvedValueOnce( expectedTransactionHash, ); - (rpcMethods.getTransactionReceipt as jest.Mock).mockResolvedValueOnce( - expectedTransactionReceipt, - ); + ( + WaitForTransactionReceipt.waitForTransactionReceipt as jest.Mock + ).mockResolvedValueOnce(expectedTransactionReceipt); const promiEvent = sendTransaction( web3Context, @@ -286,9 +288,10 @@ describe('sendTransaction', () => { await promiEvent; - expect(rpcMethods.getTransactionReceipt).toHaveBeenCalledWith( - web3Context.requestManager, + expect(WaitForTransactionReceipt.waitForTransactionReceipt).toHaveBeenCalledWith( + web3Context, expectedTransactionHash, + DEFAULT_RETURN_FORMAT, ); expect(watchTransactionForConfirmationsSpy).toHaveBeenCalledWith( web3Context, diff --git a/packages/web3-providers-ipc/src/index.ts b/packages/web3-providers-ipc/src/index.ts index 9527dfea4a3..c278d670232 100644 --- a/packages/web3-providers-ipc/src/index.ts +++ b/packages/web3-providers-ipc/src/index.ts @@ -173,6 +173,19 @@ export default class IpcProvider< await this.waitForConnection(); } + // TODO: once https://github.com/web3/web3.js/issues/5460 is implemented, remove this block. + // And catch the error by listening to the error event. + // Additionally, after both https://github.com/web3/web3.js/issues/5466 and https://github.com/web3/web3.js/issues/5467 + // are implemented. There should be no case in the tests that cause a request to the provider after closing the connection. + if (!this._socket.writable) { + console.error( + 'Can not send a request. The internal socket is not `writable`. Request data: ', + request, + ); + const dummyPromise = new Web3DeferredPromise>(); + return dummyPromise; + } + try { const defPromise = new Web3DeferredPromise>(); this._requestQueue.set(requestId, defPromise); diff --git a/packages/web3-utils/CHANGELOG.md b/packages/web3-utils/CHANGELOG.md index dc93264a3e0..61e4ce71d42 100644 --- a/packages/web3-utils/CHANGELOG.md +++ b/packages/web3-utils/CHANGELOG.md @@ -34,3 +34,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - I've improved the security in XY (#1000) --> + +## [Unreleased] + +### Added + +- Added and exported three reusable utility functions: `pollTillDefined`, `rejectIfTimeout` and `rejectIfConditionAtInterval` which are useful when dealing with promises that involves polling, rejecting after timeout or rejecting if a condition was met when calling repeatably at every time intervals. diff --git a/packages/web3-utils/src/promise_helpers.ts b/packages/web3-utils/src/promise_helpers.ts index 4caabba071a..4c5c1fb98f8 100644 --- a/packages/web3-utils/src/promise_helpers.ts +++ b/packages/web3-utils/src/promise_helpers.ts @@ -15,25 +15,32 @@ You should have received a copy of the GNU Lesser General Public License along with web3.js. If not, see . */ +import { isNullish } from 'web3-validator'; + +export type AsyncFunction = (...args: K[]) => Promise; + export function waitWithTimeout( - awaitable: Promise, + awaitable: Promise | AsyncFunction, timeout: number, error: Error, ): Promise; -export function waitWithTimeout(awaitable: Promise, timeout: number): Promise; +export function waitWithTimeout( + awaitable: Promise | AsyncFunction, + timeout: number, +): Promise; /** * Wait for a promise but interrupt it if it did not resolve within a given timeout. * If the timeout reached, before the promise code resolve, either throw an error if an error object was provided, or return `undefined`. */ export async function waitWithTimeout( - awaitable: Promise, + awaitable: Promise | AsyncFunction, timeout: number, error?: Error, ): Promise { let timeoutId: NodeJS.Timeout | undefined; const result = await Promise.race([ - awaitable, + awaitable instanceof Promise ? awaitable : awaitable(), new Promise((resolve, reject) => { timeoutId = setTimeout(() => (error ? reject(error) : resolve(undefined)), timeout); }), @@ -46,3 +53,69 @@ export async function waitWithTimeout( } return result; } + +export async function pollTillDefined( + func: AsyncFunction, + interval: number, +): Promise> { + const awaitableRes = waitWithTimeout(func, interval); + + let intervalId: NodeJS.Timer | undefined; + const polledRes = new Promise>((resolve, reject) => { + intervalId = setInterval(() => { + (async () => { + try { + const res = await waitWithTimeout(func, interval); + + if (!isNullish(res)) { + clearInterval(intervalId); + resolve(res as unknown as Exclude); + } + } catch (error) { + clearInterval(intervalId); + reject(error); + } + })() as unknown; + }, interval); + }); + + // If the first call to awaitableRes succeeded, return the result + const res = await awaitableRes; + if (!isNullish(res)) { + if (intervalId) { + clearInterval(intervalId); + } + return res as unknown as Exclude; + } + + return polledRes; +} + +export function rejectIfTimeout(timeout: number, error: Error): [NodeJS.Timer, Promise] { + let timeoutId: NodeJS.Timer | undefined; + const rejectOnTimeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(error); + }, timeout); + }); + return [timeoutId as unknown as NodeJS.Timer, rejectOnTimeout]; +} + +export function rejectIfConditionAtInterval( + cond: AsyncFunction, + interval: number, +): [NodeJS.Timer, Promise] { + let intervalId: NodeJS.Timer | undefined; + const rejectIfCondition = new Promise((_, reject) => { + intervalId = setInterval(() => { + (async () => { + const error = await cond(); + if (error) { + clearInterval(intervalId); + reject(error); + } + })() as unknown; + }, interval); + }); + return [intervalId as unknown as NodeJS.Timer, rejectIfCondition]; +} diff --git a/packages/web3/package.json b/packages/web3/package.json index 9bdf8fd38cd..76b55be625c 100644 --- a/packages/web3/package.json +++ b/packages/web3/package.json @@ -22,7 +22,7 @@ "clean": "rimraf dist", "prebuild": "rimraf dist", "build": "yarn version:output && tsc --build", - "version:output": "[ -z $npm_package_version ] && rm ./src/version.ts || echo '/* eslint-disable header/header */ export const Web3PkgInfo = { version: \"'$npm_package_version'\" }' > ./src/version.ts", + "version:output": "[ -z $npm_package_version ] && rm ./src/version.ts || echo \"/* eslint-disable header/header */ export const Web3PkgInfo = { version: '$npm_package_version' };\" > ./src/version.ts", "build:web": "npx webpack", "build:check": "node -e \"require('./dist')\"", "lint": "eslint --ext .js,.ts .", diff --git a/scripts/system_tests_utils.ts b/scripts/system_tests_utils.ts index 53e94e3200a..7cab815b08a 100644 --- a/scripts/system_tests_utils.ts +++ b/scripts/system_tests_utils.ts @@ -119,6 +119,19 @@ export const closeOpenConnection = async (web3Context: Web3Context) => { await waitForOpenConnection(web3Context); } + // If an error happened during closing, that is acceptable at tests, just print a 'warn'. + if (web3Context?.provider) { + (web3Context.provider as unknown as Web3BaseProvider).on('error', (err: any) => { + console.warn('error while trying to close the connection', err); + }); + } + + // Wait a bit to ensure the connection does not have a pending data that + // could cause an error if written after closing the connection. + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + if ( web3Context?.provider && 'disconnect' in (web3Context.provider as unknown as Web3BaseProvider)