Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 165 additions & 0 deletions src/PollingBlockTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@

await withPollingBlockTracker(async ({ blockTracker }) => {
expect(blockTracker.isRunning()).toBe(false);
blockTracker.getLatestBlock();

Check warning on line 177 in src/PollingBlockTracker.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Build, Lint, and Test (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 177 in src/PollingBlockTracker.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Build, Lint, and Test (22.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 177 in src/PollingBlockTracker.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Build, Lint, and Test (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
expect(blockTracker.isRunning()).toBe(false);
});
});
Expand Down Expand Up @@ -984,6 +984,171 @@
);
});
});

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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having trouble understanding how this test asserts the description. i.e. if the block tracker didn't wait for the next block, what is stopping this test from passing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of the numAutomaticCalls: 2 would only allow 2 setTimeouts to pass. This allows 3 update loops to occur:

  1. first update happens immediately (not via setTimeout), and queues up setTimeout for second update
  2. second update happens after setTimeout is executed, and queues up setTimeout for third update
  3. third update happens after setTimeout is executed

but if the getLatestBlock() call wasn't waiting for the next block, we'd have 2 more setTimeouts we'd need to await for, which would cause the test to fail since it would need to allow 3 setTimeouts to pass.

  1. first update happens immediately (not via setTimeout), and queues up setTimeout for second update
  2. first getLatestBlock() is called, waits for second update
  3. second update happens after setTimeout is executed, and queues up setTimeout for third update
  4. first getLatestBlock() gets event, queues up setTimeout to release the pendingBlock promise
  5. second getLatestBlock() is called, waits for third update
  6. third update happens after setTimeout is executed
  7. second getLatestBlock() gets event

Copy link
Copy Markdown
Member Author

@jiexi jiexi Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, I shouldn't need to call timeoutCallbacks though given the automatic calls. Let me play around with this again

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleaned up here 17ae0e0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added explicit _waitingForNextIteration checks here f233852

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but if the getLatestBlock() call wasn't waiting for the next block, we'd have 2 more setTimeouts we'd need to await for

I was thinking of the opposite case. i.e. what if it incorrectly fetches immediately instead of waiting for the next loop. This test would still pass despite that behavior contradicting the description

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually no, the await pollingLoopPromise1 step should timeout in that case. Nevermind, I think that's covered now as well

},
);
});

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', () => {
Expand Down
71 changes: 47 additions & 24 deletions src/PollingBlockTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,11 @@ export class PollingBlockTracker
return this._currentBlock;
}

async getLatestBlock(): Promise<string> {
async getLatestBlock({
useCache = true,
}: { useCache?: boolean } = {}): Promise<string> {
// return if available
if (this._currentBlock) {
if (this._currentBlock && useCache) {
return this._currentBlock;
}

Expand All @@ -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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Stale Block Data in Latest Block Retrieval

The _updateLatestBlock method returns this._currentBlock which can be stale or null if _newPotentialLatest rejects the freshly fetched block. This causes getLatestBlock({ useCache: false }) to return outdated data or throw a runtime error. Also, when the tracker is running, getLatestBlock({ useCache: false }) waits for the next polling event instead of fetching immediately.

Additional Locations (1)

Fix in Cursor Fix in Web

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(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this is how we are ensuring we aren't making a new request when calling getLatestBlock({ useCache: false }). Nice.

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;
}
}

Expand Down Expand Up @@ -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();
Expand All @@ -302,10 +322,13 @@ export class PollingBlockTracker
this._clearPollingTimeout();
}

private async _updateLatestBlock(): Promise<void> {
private async _updateLatestBlock(): Promise<string> {
// 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<string> {
Expand Down
Loading