diff --git a/CHANGELOG.md b/CHANGELOG.md index dab4a5e..f06d25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `PollingBlockTracker.getLatestBlock()` now accepts an optional parameter `useCache` ([#340](https://github.com/MetaMask/eth-block-tracker/pull/340)) + - This option defaults to `true`, but when `false`, it ignores the cached block number and instead updates and returns a new block number, ensuring that the frequency of requests is limited to the `pollingInterval` period + ## [12.1.0] ### Changed diff --git a/src/PollingBlockTracker.test.ts b/src/PollingBlockTracker.test.ts index b940d66..385e5f6 100644 --- a/src/PollingBlockTracker.test.ts +++ b/src/PollingBlockTracker.test.ts @@ -984,6 +984,171 @@ describe('PollingBlockTracker', () => { ); }); }); + + describe('with useCache: false', () => { + describe('when the block tracker is not running', () => { + it('should not fetch a new block even if a block is already cached and less than the polling interval time has passed since the last call', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock({ + useCache: false, + }); + expect(block).toBe('0x1'); + expect(blockTracker.isRunning()).toBe(false); + }, + ); + }); + + it('should fetch a new block even if a block is already cached and more than the polling interval time has passed since the last call', async () => { + recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock({ + useCache: false, + }); + expect(block).toBe('0x2'); + expect(blockTracker.isRunning()).toBe(false); + }, + ); + }); + }); + + describe('when the block tracker is already started', () => { + it('should wait for the next block event even if a block is already cached', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + { + methodName: 'eth_blockNumber', + result: '0x3', + }, + ], + }, + }, + + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + + const blockPromise1 = blockTracker.getLatestBlock({ + useCache: false, + }); + const pollingLoopPromise1 = new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + await setTimeoutRecorder.next(); + await pollingLoopPromise1; + const block1 = await blockPromise1; + expect(block1).toBe('0x2'); + + const pollingLoopPromise2 = new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + const blockPromise2 = blockTracker.getLatestBlock({ + useCache: false, + }); + await setTimeoutRecorder.next(); + await pollingLoopPromise2; + const block2 = await blockPromise2; + expect(block2).toBe('0x3'); + }, + ); + }); + + it('should handle concurrent calls', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + + const blockPromise1 = blockTracker.getLatestBlock({ + useCache: false, + }); + const blockPromise2 = blockTracker.getLatestBlock({ + useCache: false, + }); + + const pollingLoopPromise = new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + await setTimeoutRecorder.next(); + await pollingLoopPromise; + + const block1 = await blockPromise1; + const block2 = await blockPromise2; + expect(block1).toBe('0x2'); + expect(block2).toBe('0x2'); + }, + ); + }); + }); + }); }); describe('checkForLatestBlock', () => { diff --git a/src/PollingBlockTracker.ts b/src/PollingBlockTracker.ts index a5115c2..79514f2 100644 --- a/src/PollingBlockTracker.ts +++ b/src/PollingBlockTracker.ts @@ -111,9 +111,11 @@ export class PollingBlockTracker return this._currentBlock; } - async getLatestBlock(): Promise { + async getLatestBlock({ + useCache = true, + }: { useCache?: boolean } = {}): Promise { // return if available - if (this._currentBlock) { + if (this._currentBlock && useCache) { return this._currentBlock; } @@ -126,31 +128,42 @@ export class PollingBlockTracker }); this.#pendingLatestBlock = { reject, promise }; - try { + if (this._isRunning) { + try { + // 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; + } + } else { // If tracker isn't running, just fetch directly - if (!this._isRunning) { - const latestBlock = await this._fetchLatestBlock(); - this._newPotentialLatest(latestBlock); + try { + const latestBlock = await this._updateLatestBlock(); resolve(latestBlock); return latestBlock; + } catch (error) { + reject(error); + throw error; + } finally { + // We want to rate limit calls to this method if we made a direct fetch + // for the block number because the BlockTracker was not running. We + // achieve this by delaying the unsetting of the #pendingLatestBlock promise. + setTimeout(() => { + this.#pendingLatestBlock = undefined; + }, this._pollingInterval); } - - // 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; } } @@ -287,6 +300,13 @@ export class PollingBlockTracker this._currentBlock = null; } + /** + * Checks for the latest block, updates the internal state, and returns the + * value immediately rather than waiting for the next polling interval. + * + * @deprecated Use {@link getLatestBlock} instead. + * @returns A promise that resolves to the latest block number. + */ async checkForLatestBlock() { await this._updateLatestBlock(); return await this.getLatestBlock(); @@ -302,10 +322,13 @@ export class PollingBlockTracker this._clearPollingTimeout(); } - private async _updateLatestBlock(): Promise { + private async _updateLatestBlock(): Promise { // fetch + set latest block const latestBlock = await this._fetchLatestBlock(); this._newPotentialLatest(latestBlock); + // _newPotentialLatest() ensures that this._currentBlock is not null + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this._currentBlock!; } private async _fetchLatestBlock(): Promise {