diff --git a/src/PollingBlockTracker.test.ts b/src/PollingBlockTracker.test.ts index e367efa..3d6523f 100644 --- a/src/PollingBlockTracker.test.ts +++ b/src/PollingBlockTracker.test.ts @@ -142,237 +142,186 @@ describe('PollingBlockTracker', () => { }); describe('getLatestBlock', () => { - it('should start the block tracker immediately after being called', async () => { - recordCallsToSetTimeout(); - - await withPollingBlockTracker(async ({ blockTracker }) => { - const promiseToGetLatestBlock = blockTracker.getLatestBlock(); - expect(blockTracker.isRunning()).toBe(true); - // We have to wait for the promise to resolve after the assertion - // because by the time this promise resolves, the block tracker isn't - // running anymore - await promiseToGetLatestBlock; - }); - }); - - it('should stop the block tracker automatically after its promise is fulfilled', async () => { - recordCallsToSetTimeout(); - - await withPollingBlockTracker(async ({ blockTracker }) => { - await blockTracker.getLatestBlock(); - expect(blockTracker.isRunning()).toBe(false); - }); - }); - - it('should fetch the latest block number', async () => { - recordCallsToSetTimeout(); - - await withPollingBlockTracker( - { - provider: { - stubs: [ - { - methodName: 'eth_blockNumber', - result: '0x0', - }, - ], - }, - }, - async ({ blockTracker }) => { - const latestBlockNumber = await blockTracker.getLatestBlock(); - expect(latestBlockNumber).toBe('0x0'); - }, - ); - }); - - it('should return a promise that rejects if the request for the block number fails and the block tracker is then stopped', async () => { - recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + describe('when the block tracker is not running', () => { + describe('if no other concurrent call exists', () => { + describe('if the latest block number has already been fetched once', () => { + it('returns the block number', async () => { + recordCallsToSetTimeout(); - await withPollingBlockTracker( - { - provider: { - stubs: [ + await withPollingBlockTracker( { - methodName: 'eth_blockNumber', - error: new Error('boom'), + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, }, - ], - }, - }, - async ({ blockTracker }) => { - const latestBlockPromise = blockTracker.getLatestBlock(); - - expect(blockTracker.isRunning()).toBe(true); - await blockTracker.destroy(); - await expect(latestBlockPromise).rejects.toThrow( - 'Block tracker destroyed', - ); - expect(blockTracker.isRunning()).toBe(false); - }, - ); - }); - - it('should not retry failed requests after the block tracker is stopped', async () => { - recordCallsToSetTimeout({ numAutomaticCalls: 2 }); - - await withPollingBlockTracker( - { - provider: { - stubs: [ - { - methodName: 'eth_blockNumber', - error: new Error('boom'), + async ({ blockTracker }) => { + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x1'); }, - ], - }, - }, - async ({ blockTracker, provider }) => { - const requestSpy = jest.spyOn(provider, 'request'); - - const latestBlockPromise = blockTracker.getLatestBlock(); - await blockTracker.destroy(); - - await expect(latestBlockPromise).rejects.toThrow( - 'Block tracker destroyed', - ); - expect(requestSpy).toHaveBeenCalledTimes(1); - expect(requestSpy).toHaveBeenCalledWith({ - jsonrpc: '2.0', - id: expect.any(Number), - method: 'eth_blockNumber', - params: [], + ); }); - }, - ); - }); - - it('should return a promise that resolves when a new block is available', async () => { - recordCallsToSetTimeout(); + }); - await withPollingBlockTracker( - { - provider: { - stubs: [ - { - methodName: 'eth_blockNumber', - result: '0x1', - }, - ], - }, - }, - async ({ blockTracker }) => { - expect(await blockTracker.getLatestBlock()).toBe('0x1'); - }, - ); - }); + describe('if the latest block number has not been fetched yet', () => { + it('does not start the block tracker', async () => { + recordCallsToSetTimeout(); - it('should resolve all returned promises when a new block is available', async () => { - recordCallsToSetTimeout(); + await withPollingBlockTracker(async ({ blockTracker }) => { + expect(blockTracker.isRunning()).toBe(false); + blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(false); + }); + }); - await withPollingBlockTracker( - { - provider: { - stubs: [ - { - methodName: 'eth_blockNumber', - result: '0x1', - }, - ], - }, - }, - async ({ blockTracker }) => { - const promises = [ - blockTracker.getLatestBlock(), - blockTracker.getLatestBlock(), - ]; + describe('if the latest block number is successfully fetched', () => { + it('returns the fetched latest block number', async () => { + recordCallsToSetTimeout(); - expect(await Promise.all(promises)).toStrictEqual(['0x1', '0x1']); - }, - ); - }); + await withPollingBlockTracker(async ({ blockTracker }) => { + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + }); + }); + }); - it('request the latest block number with `skipCache: true` if the block tracker was initialized with `setSkipCacheFlag: true`', async () => { - recordCallsToSetTimeout(); + describe('if an error occurs while fetching the latest block number', () => { + it('re-throws the error', async () => { + recordCallsToSetTimeout(); - await withPollingBlockTracker( - { blockTracker: { setSkipCacheFlag: true } }, - async ({ provider, blockTracker }) => { - jest.spyOn(provider, 'request'); + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + ], + }, + }, + async ({ blockTracker }) => { + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + }, + ); + }); - await blockTracker.getLatestBlock(); + it('does not emit "error"', async () => { + recordCallsToSetTimeout(); - expect(provider.request).toHaveBeenCalledWith({ - jsonrpc: '2.0' as const, - id: expect.any(Number), - method: 'eth_blockNumber' as const, - params: [], - skipCache: true, + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker.on('error', errorListener); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).not.toHaveBeenCalled(); + }, + ); + }); }); - }, - ); - }); + }); + }); - it('should not ask for a new block number while the current block number is cached', async () => { - recordCallsToSetTimeout(); + describe('if already called concurrently', () => { + describe('if the latest block number is successfully fetched', () => { + it('returns the block number that the other call returns', async () => { + recordCallsToSetTimeout(); - await withPollingBlockTracker(async ({ provider, blockTracker }) => { - const requestSpy = jest.spyOn(provider, 'request'); - await blockTracker.getLatestBlock(); - await blockTracker.getLatestBlock(); - const requestsForLatestBlock = requestSpy.mock.calls.filter((args) => { - return args[0].method === 'eth_blockNumber'; + await withPollingBlockTracker(async ({ blockTracker }) => { + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + const [block1, block2] = await Promise.all([promise1, promise2]); + expect(block1).toBe(block2); + }); + }); }); - expect(requestsForLatestBlock).toHaveLength(1); - }); - }); - it('should ask for a new block number after the cached one is cleared', async () => { - const setTimeoutRecorder = recordCallsToSetTimeout(); - const blockTrackerOptions = { - pollingInterval: 100, - blockResetDuration: 200, - }; + describe('if an error occurs while fetching the latest block number', () => { + it('throws the error that the other call throws', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); - await withPollingBlockTracker( - { - provider: { - stubs: [ + await withPollingBlockTracker( { - methodName: 'eth_blockNumber', - result: '0x0', + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, }, - { - methodName: 'eth_blockNumber', - result: '0x1', + async ({ blockTracker }) => { + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await expect(promise1).rejects.toThrow(thrownError); + await expect(promise2).rejects.toThrow(thrownError); }, - ], + ); + }); + }); + }); + + it('request the latest block number with `skipCache: true` if the block tracker was initialized with `setSkipCacheFlag: true`', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { blockTracker: { setSkipCacheFlag: true } }, + async ({ provider, blockTracker }) => { + jest.spyOn(provider, 'request'); + + await blockTracker.getLatestBlock(); + + expect(provider.request).toHaveBeenCalledWith({ + jsonrpc: '2.0' as const, + id: expect.any(Number), + method: 'eth_blockNumber' as const, + params: [], + skipCache: true, + }); }, - blockTracker: blockTrackerOptions, - }, - async ({ provider, blockTracker }) => { + ); + }); + + it('should not ask for a new block number while the current block number is cached', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ provider, blockTracker }) => { const requestSpy = jest.spyOn(provider, 'request'); await blockTracker.getLatestBlock(); - // When the block tracker stops, there may be two `setTimeout`s in - // play: one to go to the next iteration of the block tracker - // loop, another to expire the current block number cache. We don't - // know which one has been added first, so we have to find it. - await setTimeoutRecorder.nextMatchingDuration( - blockTrackerOptions.blockResetDuration, - ); await blockTracker.getLatestBlock(); const requestsForLatestBlock = requestSpy.mock.calls.filter( (args) => { return args[0].method === 'eth_blockNumber'; }, ); - expect(requestsForLatestBlock).toHaveLength(2); - }, - ); + expect(requestsForLatestBlock).toHaveLength(1); + }); + }); }); - METHODS_TO_ADD_LISTENER.forEach((methodToAddListener) => { - it(`should emit the "error" event (added via \`${methodToAddListener}\`) and should not throw if, while making the request for the latest block number, the provider throws`, async () => { - const thrownError = new Error('boom'); + describe('when the block tracker is already started', () => { + it('should return a promise that rejects if the request for the block number fails and the block tracker is then stopped', async () => { recordCallsToSetTimeout({ numAutomaticCalls: 1 }); await withPollingBlockTracker( @@ -381,32 +330,27 @@ describe('PollingBlockTracker', () => { stubs: [ { methodName: 'eth_blockNumber', - implementation: () => { - throw thrownError; - }, - }, - { - methodName: 'eth_blockNumber', - result: '0x0', + error: new Error('boom'), }, ], }, }, async ({ blockTracker }) => { - const errorListener = jest.fn(); - blockTracker[methodToAddListener]('error', errorListener); + blockTracker.on('latest', EMPTY_FUNCTION); - const promiseForLatestBlock = blockTracker.getLatestBlock(); + const latestBlockPromise = blockTracker.getLatestBlock(); - const latestBlock = await promiseForLatestBlock; - expect(errorListener).toHaveBeenCalledWith(thrownError); - expect(latestBlock).toBe('0x0'); + expect(blockTracker.isRunning()).toBe(true); + await blockTracker.destroy(); + await expect(latestBlockPromise).rejects.toThrow( + 'Block tracker destroyed', + ); + expect(blockTracker.isRunning()).toBe(false); }, ); }); - it(`should emit the "error" event (added via \`${methodToAddListener}\`) and should not throw if, while making the request for the latest block number, the provider rejects with an error`, async () => { - const thrownError = new Error('boom'); + it('should not retry failed requests after the block tracker is stopped', async () => { recordCallsToSetTimeout({ numAutomaticCalls: 1 }); await withPollingBlockTracker( @@ -415,147 +359,468 @@ describe('PollingBlockTracker', () => { stubs: [ { methodName: 'eth_blockNumber', - error: thrownError, - }, - { - methodName: 'eth_blockNumber', - result: '0x0', + error: new Error('boom'), }, ], }, }, - async ({ blockTracker }) => { - const errorListener = jest.fn(); - blockTracker[methodToAddListener]('error', errorListener); + async ({ blockTracker, provider }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const requestSpy = jest.spyOn(provider, 'request'); - const promiseForLatestBlock = blockTracker.getLatestBlock(); + const latestBlockPromise = blockTracker.getLatestBlock(); + await blockTracker.destroy(); - const latestBlock = await promiseForLatestBlock; - expect(errorListener).toHaveBeenCalledWith(thrownError); - expect(latestBlock).toBe('0x0'); + await expect(latestBlockPromise).rejects.toThrow( + 'Block tracker destroyed', + ); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(Number), + method: 'eth_blockNumber', + params: [], + }); }, ); }); - }); - - it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { - const thrownError = new Error('boom'); - recordCallsToSetTimeout({ numAutomaticCalls: 1 }); - await withPollingBlockTracker( - { - provider: { - stubs: [ - { - methodName: 'eth_blockNumber', - implementation: () => { - throw thrownError; + it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: () => { + throw thrownError; + }, }, - }, - ], + ], + }, }, - }, - async ({ blockTracker }) => { - jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); - blockTracker.getLatestBlock(); - await new Promise((resolve) => { - blockTracker.on('_waitingForNextIteration', resolve); - }); + await expect(blockTracker.getLatestBlock()).rejects.toThrow('boom'); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); - expect(console.error).toHaveBeenCalledWith( - 'Error updating latest block: boom', - ); - }, - ); - }); + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); - it('should log an error if, while requesting the latest block number, the provider rejects and there is nothing listening to "error"', async () => { - const thrownError = new Error('boom'); - recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + it('should log an error if, while requesting the latest block number, the provider rejects and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); - await withPollingBlockTracker( - { - provider: { - stubs: [ - { - methodName: 'eth_blockNumber', - error: thrownError, - }, - ], + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, }, - }, - async ({ blockTracker }) => { - jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); - blockTracker.getLatestBlock(); - await new Promise((resolve) => { - blockTracker.on('_waitingForNextIteration', resolve); + await expect(blockTracker.getLatestBlock()).rejects.toThrow('boom'); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should update the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + }, + ); + }); + + it('should clear the current block number some time after being called', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + await blockTracker.destroy(); + + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + describe('if no other concurrent call exists', () => { + describe('if the latest block number has already been fetched once', () => { + it('returns the block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + }); + }); + }); + + describe('if the latest block number has not been fetched yet', () => { + describe('if the latest block number is successfully fetched on the next poll iteration', () => { + it('returns the fetched latest block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + }); + }); + + it('does not stop the block tracker once complete', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(true); + }); + }); }); - expect(console.error).toHaveBeenCalledWith( - 'Error updating latest block: boom', - ); - }, - ); - }); + describe('if an error occurs while fetching the latest block number on the next poll iteration', () => { + it('emits "error" if anything is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); - it('should update the current block number', async () => { - recordCallsToSetTimeout(); + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker.on('error', errorListener); + blockTracker.on('latest', EMPTY_FUNCTION); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).toHaveBeenCalledWith(thrownError); + }, + ); + }); - await withPollingBlockTracker( - { - provider: { - stubs: [ + it('logs an error if nothing is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + jest + .spyOn(console, 'error') + .mockImplementation(EMPTY_FUNCTION); + blockTracker.on('latest', EMPTY_FUNCTION); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('does not stop the block tracker once complete', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + try { + await blockTracker.getLatestBlock(); + } catch { + // do nothing + } + expect(blockTracker.isRunning()).toBe(true); + }, + ); + }); + }); + }); + }); + + describe('if already called concurrently', () => { + describe('if the latest block number is successfully fetched on the next poll iteration', () => { + it('returns the block number that the other call returns', async () => { + recordCallsToSetTimeout(); + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + const [block1, block2] = await Promise.all([promise1, promise2]); + expect(block1).toBe(block2); + }); + }); + + it('does not stop the block tracker once complete', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(true); + }); + }); + }); + + describe('if an error occurs while fetching the latest block number on the next poll iteration', () => { + it('throws the error that the other call throws', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( { - methodName: 'eth_blockNumber', - result: '0x0', + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, }, - ], - }, - }, - async ({ blockTracker }) => { - await blockTracker.getLatestBlock(); - const currentBlockNumber = blockTracker.getCurrentBlock(); - expect(currentBlockNumber).toBe('0x0'); - }, - ); - }); + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await expect(promise1).rejects.toThrow(thrownError); + await expect(promise2).rejects.toThrow(thrownError); + }, + ); + }); - it('should clear the current block number some time after being called', async () => { - const setTimeoutRecorder = recordCallsToSetTimeout(); - const blockTrackerOptions = { - pollingInterval: 100, - blockResetDuration: 200, - }; + it('emits "error" only once if anything is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); - await withPollingBlockTracker( - { - provider: { - stubs: [ + await withPollingBlockTracker( { - methodName: 'eth_blockNumber', - result: '0x0', + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, }, - ], - }, - blockTracker: blockTrackerOptions, - }, - async ({ blockTracker }) => { - await blockTracker.getLatestBlock(); - const currentBlockNumber = blockTracker.getCurrentBlock(); - expect(currentBlockNumber).toBe('0x0'); + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker.on('error', errorListener); + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await Promise.allSettled([promise1, promise2]); + expect(errorListener).toHaveBeenCalledTimes(1); + }, + ); + }); - // When the block tracker stops, there may be two `setTimeout`s in - // play: one to go to the next iteration of the block tracker - // loop, another to expire the current block number cache. We don't - // know which one has been added first, so we have to find it. - await setTimeoutRecorder.nextMatchingDuration( - blockTrackerOptions.blockResetDuration, + it('logs an error only once if nothing is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await Promise.allSettled([promise1, promise2]); + expect(console.error).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not stop the block tracker once complete', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(true); + }); + }); + }); + }); + + METHODS_TO_ADD_LISTENER.forEach((methodToAddListener) => { + it(`should throw and emit the "error" event (added via \`${methodToAddListener}\`) if, while making the request for the latest block number, the provider throws`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: () => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + const errorListener = jest.fn(); + expect(blockTracker.isRunning()).toBe(true); + blockTracker[methodToAddListener]('error', errorListener); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).toHaveBeenCalledWith(thrownError); + const latestBlock = await blockTracker.getLatestBlock(); + expect(latestBlock).toBe('0x0'); + }, ); - expect(blockTracker.getCurrentBlock()).toBeNull(); - }, - ); + }); + + it(`should throw and emit the "error" event (added via \`${methodToAddListener}\`) if, while making the request for the latest block number, the provider rejects with an error`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + const errorListener = jest.fn(); + expect(blockTracker.isRunning()).toBe(true); + blockTracker[methodToAddListener]('error', errorListener); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).toHaveBeenCalledWith(thrownError); + const latestBlock = await blockTracker.getLatestBlock(); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + }); }); }); diff --git a/src/PollingBlockTracker.ts b/src/PollingBlockTracker.ts index 9b60d33..a72c6eb 100644 --- a/src/PollingBlockTracker.ts +++ b/src/PollingBlockTracker.ts @@ -63,6 +63,8 @@ export class PollingBlockTracker #pendingLatestBlock?: Omit, 'resolve'>; + #pendingFetch?: Omit, 'resolve'>; + constructor(opts: PollingBlockTrackerOptions = {}) { // parse + validate args if (!opts.provider) { @@ -114,7 +116,9 @@ export class PollingBlockTracker // return if available if (this._currentBlock) { return this._currentBlock; - } else if (this.#pendingLatestBlock) { + } + + if (this.#pendingLatestBlock) { return await this.#pendingLatestBlock.promise; } @@ -123,15 +127,32 @@ export class PollingBlockTracker }); this.#pendingLatestBlock = { reject, promise }; - // wait for a new latest block - const onLatestBlock = (value: string) => { - this.#removeInternalListener(onLatestBlock); - resolve(value); + try { + // If tracker isn't running, just fetch directly + if (!this._isRunning) { + const latestBlock = await this._fetchLatestBlock(); + this._newPotentialLatest(latestBlock); + resolve(latestBlock); + return latestBlock; + } + + // If tracker is running, wait for next block with timeout + const onLatestBlock = (value: string) => { + this.#removeInternalListener(onLatestBlock); + this.removeListener('latest', onLatestBlock); + resolve(value); + }; + + this.#addInternalListener(onLatestBlock); + this.once('latest', onLatestBlock); + + return await promise; + } catch (error) { + reject(error); + throw error; + } finally { this.#pendingLatestBlock = undefined; - }; - this.#addInternalListener(onLatestBlock); - this.once('latest', onLatestBlock); - return await promise; + } } // dont allow module consumer to remove our internal event listeners @@ -266,7 +287,6 @@ export class PollingBlockTracker this._currentBlock = null; } - // trigger block polling async checkForLatestBlock() { await this._updateLatestBlock(); return await this.getLatestBlock(); @@ -289,24 +309,40 @@ export class PollingBlockTracker } private async _fetchLatestBlock(): Promise { - const req: ExtendedJsonRpcRequest = { - jsonrpc: '2.0', - id: createRandomId(), - method: 'eth_blockNumber', - params: [] as [], - }; - if (this._setSkipCacheFlag) { - req.skipCache = true; + // If there's already a pending fetch, reuse it + if (this.#pendingFetch) { + return await this.#pendingFetch.promise; } - log('Making request', req); + // Create a new deferred promise for this request + const { promise, resolve, reject } = createDeferredPromise({ + suppressUnhandledRejection: true, + }); + this.#pendingFetch = { reject, promise }; + try { + const req: ExtendedJsonRpcRequest = { + jsonrpc: '2.0', + id: createRandomId(), + method: 'eth_blockNumber', + params: [] as [], + }; + if (this._setSkipCacheFlag) { + req.skipCache = true; + } + + log('Making request', req); const result = await this._provider.request<[], string>(req); log('Got result', result); + resolve(result); return result; } catch (error) { log('Encountered error fetching block', getErrorMessage(error)); + reject(error); + this.#rejectPendingLatestBlock(error); throw error; + } finally { + this.#pendingFetch = undefined; } }