diff --git a/packages/network/src/client/thor/blocks/block-client.ts b/packages/network/src/client/thor/blocks/blocks-client.ts similarity index 97% rename from packages/network/src/client/thor/blocks/block-client.ts rename to packages/network/src/client/thor/blocks/blocks-client.ts index ba8910426..a1fed4ab5 100644 --- a/packages/network/src/client/thor/blocks/block-client.ts +++ b/packages/network/src/client/thor/blocks/blocks-client.ts @@ -7,7 +7,7 @@ import { type BlockDetail } from './types'; * The `BlockClient` class provides methods to interact with block-related endpoints * of the VechainThor blockchain. It allows fetching details of a specific blockchain block. */ -class BlockClient { +class BlocksClient { /** * Initializes a new instance of the `BlockClient` class. * @param httpClient - The HTTP client instance used for making HTTP requests. @@ -61,4 +61,4 @@ class BlockClient { } } -export { BlockClient }; +export { BlocksClient }; diff --git a/packages/network/src/client/thor/blocks/index.ts b/packages/network/src/client/thor/blocks/index.ts index ed5547a17..ce63ce7a7 100644 --- a/packages/network/src/client/thor/blocks/index.ts +++ b/packages/network/src/client/thor/blocks/index.ts @@ -1,2 +1,2 @@ export * from './types.d'; -export * from './block-client'; +export * from './blocks-client'; diff --git a/packages/network/src/client/thor/index.ts b/packages/network/src/client/thor/index.ts index e899c7d7c..3be7b9ef2 100644 --- a/packages/network/src/client/thor/index.ts +++ b/packages/network/src/client/thor/index.ts @@ -1,5 +1,6 @@ export * from './accounts'; export * from './blocks'; export * from './logs'; +export * from './nodes'; export * from './transactions'; export * from './thor-client'; diff --git a/packages/network/src/client/thor/nodes/index.ts b/packages/network/src/client/thor/nodes/index.ts new file mode 100644 index 000000000..1b9d9d1d3 --- /dev/null +++ b/packages/network/src/client/thor/nodes/index.ts @@ -0,0 +1 @@ +export * from './nodes-client'; diff --git a/packages/network/src/client/thor/nodes/nodes-client.ts b/packages/network/src/client/thor/nodes/nodes-client.ts new file mode 100644 index 000000000..bc4f8087f --- /dev/null +++ b/packages/network/src/client/thor/nodes/nodes-client.ts @@ -0,0 +1,96 @@ +import { type HttpClient } from '../../http'; +import { type BlockDetail, BlocksClient } from '../blocks'; +import { buildError, DATA } from '@vechain-sdk/errors'; +import { NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS } from '../../../utils'; + +/** + * Provides utility method for checking the health of a node. + */ +class NodesClient { + /** + * Internal blocks client instance used for interacting with block-related endpoints. + */ + private readonly blocksClient: BlocksClient; + + /** + * Initializes a new instance of the `NodeClient` class. + * @param httpClient - The HTTP client instance used for making HTTP requests. + */ + constructor(readonly httpClient: HttpClient) { + this.blocksClient = new BlocksClient(httpClient); + } + + /** + * Checks the health of a node using the following algorithm: + * 1. Make an HTTP GET request to retrieve the last block timestamp. + * 2. Calculates the difference between the current time and the last block timestamp. + * 3. If the difference is less than the tolerance, the node is healthy. + * Note, we could also check '/node/network/peers since' but the difficulty with this approach is + * if you consider a scenario where the node is connected to 20+ peers, which is healthy, and it receives the new blocks as expected. + * But what if the node's disk is full, and it's not writing the new blocks to its database? In this case the node is off-sync even + * though it's technically alive and connected + * @returns A boolean indicating whether the node is healthy. + * @throws {InvalidDataTypeError} - if the timestamp key does not exist in the response from the API call to the node + * @throws {InvalidDataTypeError} - if the timestamp key exists in the response from the API call to the node but the value is not a number + * @throws {InvalidDataTypeError} - if the response from the API call to the node is not an object + * @throws {InvalidDataTypeError} - if the response from the API call to the node is null or undefined + */ + public async isHealthy(): Promise { + /** + * @internal + * Perform an HTTP GET request using the SimpleNet instance to get the latest block + */ + const response = await this.blocksClient.getBestBlock(); + + /** + * timestamp from the last block + * @internal + */ + const lastBlockTimestamp: number = this.getTimestampFromBlock(response); + + /** + * seconds elapsed since the timestamp of the last block + * @internal + */ + const secondsSinceLastBlock = + Math.floor(Date.now() / 1000) - lastBlockTimestamp; + + return ( + Math.abs(secondsSinceLastBlock) < + NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS + ); + } + + /** + * Extracts the timestamp from the block + * @remarks + * This function throws an error if the timestamp key does not exist in the response from the API call to the node + * @param response the response from the API call to the node + * @returns the timestamp from the block + * @throws {InvalidDataTypeError} - if the timestamp key does not exist in the response from the API call to the node + * @throws {InvalidDataTypeError} - if the timestamp key exists in the response from the API call to the node but the value is not a number + * @throws {InvalidDataTypeError} - if the response from the API call to the node is not an object + * @throws {InvalidDataTypeError} - if the response from the API call to the node is null or undefined + */ + private readonly getTimestampFromBlock = ( + response: BlockDetail | null + ): number => { + if ( + response === null || + response === undefined || + typeof response !== 'object' || + !('timestamp' in response) || + typeof response.timestamp !== 'number' + ) { + throw buildError( + DATA.INVALID_DATA_TYPE, + 'Invalid block format returned from node. The block must be an object with a timestamp key present of type number', + { response } + ); + } + + return response.timestamp; + }; +} + +export { NodesClient }; diff --git a/packages/network/src/client/thor/thor-client.ts b/packages/network/src/client/thor/thor-client.ts index 44722dac1..ba35282cc 100644 --- a/packages/network/src/client/thor/thor-client.ts +++ b/packages/network/src/client/thor/thor-client.ts @@ -1,8 +1,9 @@ import { type HttpClient } from '../http'; import { AccountClient } from './accounts'; -import { BlockClient } from './blocks'; +import { BlocksClient } from './blocks'; import { LogsClient } from './logs'; import { TransactionClient } from './transactions'; +import { NodesClient } from './nodes'; /** * The `ThorClient` class serves as an interface to interact with the Vechain Thor blockchain. @@ -17,7 +18,7 @@ class ThorClient { /** * The `BlockClient` instance used for interacting with block-related endpoints. */ - public readonly blocks: BlockClient; + public readonly blocks: BlocksClient; /** * The `LogsClient` instance used for interacting with log-related endpoints. @@ -29,14 +30,20 @@ class ThorClient { */ public readonly transactions: TransactionClient; + /** + * The `NodeClient` instance used for interacting with node-related endpoints. + */ + public readonly nodes: NodesClient; + /** * Constructs a new `ThorClient` instance with a given HTTP client. * @param httpClient - The HTTP client instance used for making network requests. */ constructor(protected readonly httpClient: HttpClient) { this.accounts = new AccountClient(httpClient); - this.blocks = new BlockClient(httpClient); + this.blocks = new BlocksClient(httpClient); this.logs = new LogsClient(httpClient); + this.nodes = new NodesClient(httpClient); this.transactions = new TransactionClient(httpClient); } } diff --git a/packages/network/src/utils/const/client/index.ts b/packages/network/src/utils/const/client/index.ts index 29d4d49b6..b5dd63a3d 100644 --- a/packages/network/src/utils/const/client/index.ts +++ b/packages/network/src/utils/const/client/index.ts @@ -1 +1,2 @@ export * from './http-client'; +export * from './nodes'; diff --git a/packages/network/src/utils/const/client/nodes.ts b/packages/network/src/utils/const/client/nodes.ts new file mode 100644 index 000000000..22be4cf56 --- /dev/null +++ b/packages/network/src/utils/const/client/nodes.ts @@ -0,0 +1,9 @@ +/** + * Node healtcheck Tolerance in seconds. + * @example When set to 30, it means that we consider a node healthy even when it's off-sync by roughly 3 blocks. + * + * @public + */ +const NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS = 30; + +export { NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS }; diff --git a/packages/network/tests/client/thor-client/node/fixture.ts b/packages/network/tests/client/thor-client/node/fixture.ts new file mode 100644 index 000000000..c420b5901 --- /dev/null +++ b/packages/network/tests/client/thor-client/node/fixture.ts @@ -0,0 +1,91 @@ +/** + * @internal + * Block with a timestamp much older than the current time + */ +const blockWithOldTimeStamp = { + number: 16935885, + id: '0x01026bcde286e4c5b55507477edc666bb79b41ea97b6e78d65726fe557131533', + size: 361, + parentID: + '0x01026bcc5214fdc936b9afd15460479dfe35972219f78f322e23cc8184a035ab', + timestamp: 16993933000, + gasLimit: 30000000, + beneficiary: '0xb4094c25f86d628fdd571afc4077f0d0196afb48', + gasUsed: 0, + totalScore: 131653862, + txsRoot: + '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + txsFeatures: 1, + stateRoot: + '0x0b43423ced22d182d73728e47fef395169bff38c725dfdb84589e3cedfad2db2', + receiptsRoot: + '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + com: true, + signer: '0xd6fab81fd54b989655b42d51b0344ddcb5007a5a', + isTrunk: true, + isFinalized: false, + transactions: [] +}; + +/** + * @internal + * Block with a missing timestamp + */ +const blockWithMissingTimeStamp = { + number: 16935885, + id: '0x01026bcde286e4c5b55507477edc666bb79b41ea97b6e78d65726fe557131533', + size: 361, + parentID: + '0x01026bcc5214fdc936b9afd15460479dfe35972219f78f322e23cc8184a035ab', + gasLimit: 30000000, + beneficiary: '0xb4094c25f86d628fdd571afc4077f0d0196afb48', + gasUsed: 0, + totalScore: 131653862, + txsRoot: + '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + txsFeatures: 1, + stateRoot: + '0x0b43423ced22d182d73728e47fef395169bff38c725dfdb84589e3cedfad2db2', + receiptsRoot: + '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + com: true, + signer: '0xd6fab81fd54b989655b42d51b0344ddcb5007a5a', + isTrunk: true, + isFinalized: false, + transactions: [] +}; + +/** + * @internal + * Block with an invalid timestamp format + */ +const blockWithInvalidTimeStampFormat = { + number: 16935885, + id: '0x01026bcde286e4c5b55507477edc666bb79b41ea97b6e78d65726fe557131533', + size: 361, + parentID: + '0x01026bcc5214fdc936b9afd15460479dfe35972219f78f322e23cc8184a035ab', + timestamp: 'bad timestamp type', + gasLimit: 30000000, + beneficiary: '0xb4094c25f86d628fdd571afc4077f0d0196afb48', + gasUsed: 0, + totalScore: 131653862, + txsRoot: + '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + txsFeatures: 1, + stateRoot: + '0x0b43423ced22d182d73728e47fef395169bff38c725dfdb84589e3cedfad2db2', + receiptsRoot: + '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + com: true, + signer: '0xd6fab81fd54b989655b42d51b0344ddcb5007a5a', + isTrunk: true, + isFinalized: false, + transactions: [] +}; + +export { + blockWithOldTimeStamp, + blockWithMissingTimeStamp, + blockWithInvalidTimeStampFormat +}; diff --git a/packages/network/tests/client/thor-client/node/node.integration.test.ts b/packages/network/tests/client/thor-client/node/node.integration.test.ts new file mode 100644 index 000000000..214bd5eb0 --- /dev/null +++ b/packages/network/tests/client/thor-client/node/node.integration.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from '@jest/globals'; +import { thorSoloClient } from '../../../fixture'; +import { HttpClient, ThorClient } from '../../../../src'; +import { HTTPClientError } from '@vechain-sdk/errors'; + +/** + * Node integration tests + * @group integration/node + */ +describe('Integration tests to check the Node health check for different scenarios', () => { + test('valid URL but inaccessible VeChain node', async () => { + /** + * client required to access a node + * @internal + */ + const thorClient = new ThorClient(new HttpClient('www.google.ie')); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + HTTPClientError + ); + }); + + test('invalid URL', async () => { + /** + * client required to access a node + * @internal + */ + const thorClient = new ThorClient(new HttpClient('INVALID_URL')); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + HTTPClientError + ); + }); + + test('valid and available synchronized node', async () => { + const healtyNode = await thorSoloClient.nodes.isHealthy(); + expect(healtyNode).toBe(true); + }); + + test('null or empty URL or blank URL', async () => { + let thorClient = new ThorClient(new HttpClient('')); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + HTTPClientError + ); + thorClient = new ThorClient(new HttpClient(' ')); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + HTTPClientError + ); + }); +}); diff --git a/packages/network/tests/client/thor-client/node/node.unit.test.ts b/packages/network/tests/client/thor-client/node/node.unit.test.ts new file mode 100644 index 000000000..14be0f6d2 --- /dev/null +++ b/packages/network/tests/client/thor-client/node/node.unit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import { HttpClient, ThorClient } from '../../../../src'; +import { + blockWithMissingTimeStamp, + blockWithOldTimeStamp, + blockWithInvalidTimeStampFormat +} from './fixture'; +import { InvalidDataTypeError } from '@vechain-sdk/errors'; + +/** + * Node unit tests + * @group unit/node + */ +describe('Unit tests to check the Node health check is working for different scenarios', () => { + /** + * @internal + * a well-formed URL to ensure we get to the axios call in the node health check + */ + const URL = 'http://example.com'; + + test('valid URL/node but Error is thrown by network provider', async () => { + // Mock an error on the HTTPClient + jest.spyOn(HttpClient.prototype, 'http').mockImplementation(() => { + throw new Error(); + }); + + /** + * client required to access a node + * @internal + */ + const thorClient = new ThorClient(new HttpClient(URL)); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError(); + }); + + test('valid/available node but invalid block format', async () => { + // Mock the response to force the JSON response to be null + jest.spyOn(HttpClient.prototype, 'http').mockResolvedValueOnce({}); + let thorClient = new ThorClient(new HttpClient(URL)); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + InvalidDataTypeError + ); + + // Mock the response to force the JSON response to not be an object + jest.spyOn(HttpClient.prototype, 'http').mockResolvedValueOnce({ + invalidKey: 1 + }); + thorClient = new ThorClient(new HttpClient(URL)); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + InvalidDataTypeError + ); + + // Mock the response to force the JSON response to have a timestamp non-existent + jest.spyOn(HttpClient.prototype, 'http').mockResolvedValueOnce( + blockWithMissingTimeStamp + ); + thorClient = new ThorClient(new HttpClient(URL)); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + InvalidDataTypeError + ); + + // Mock the response to force the JSON response to have a timestamp not a number + jest.spyOn(HttpClient.prototype, 'http').mockResolvedValueOnce( + blockWithInvalidTimeStampFormat + ); + thorClient = new ThorClient(new HttpClient(URL)); + await expect(thorClient.nodes.isHealthy()).rejects.toThrowError( + InvalidDataTypeError + ); + }); + + test('valid & available node but node is out of sync', async () => { + // Mock the response to force the JSON response to be out of sync (i.e. > 30 seconds) + jest.spyOn(HttpClient.prototype, 'http').mockResolvedValueOnce( + blockWithOldTimeStamp + ); + const thorClient = new ThorClient(new HttpClient(URL)); + await expect(thorClient.nodes.isHealthy()).resolves.toBe(false); + }); +});