diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index 467e8e4ce0e..b2f44b8eac3 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -7,6 +7,24 @@ import determineStatus from '../determineStatus.js'; import ContainerIsNotPresentError from '../../docker/errors/ContainerIsNotPresentError.js'; import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; +function parseProtocolVersion(protocolVersion) { + // The value is Tenderdash serializing a uint64. Accept only a primitive + // that is entirely a plain decimal integer: prefix parsing ('3.5' -> 3) or + // array coercion (['3'] -> '3') would mask the node_info fallback with a + // bogus active version. + if (typeof protocolVersion !== 'string' && typeof protocolVersion !== 'number') { + return null; + } + + if (!/^\d+$/.test(String(protocolVersion).trim())) { + return null; + } + + const parsedProtocolVersion = Number(protocolVersion); + + return Number.isSafeInteger(parsedProtocolVersion) ? parsedProtocolVersion : null; +} + /** * @returns {getPlatformScopeFactory} * @param {DockerCompose} dockerCompose @@ -116,10 +134,14 @@ export default function getPlatformScopeFactory( tenderdashStatusResponse, tenderdashNetInfoResponse, tenderdashAbciInfoResponse, + tenderdashConsensusParams, ] = await Promise.all([ fetch(`http://${tenderdashHost}:${port}/status`), fetch(`http://${tenderdashHost}:${port}/net_info`), fetch(`http://${tenderdashHost}:${port}/abci_info`), + fetch(`http://${tenderdashHost}:${port}/consensus_params`) + .then((response) => response.json()) + .catch(() => null), ]); const [tenderdashStatus, tenderdashNetInfo, tenderdashAbciInfo] = await Promise.all([ @@ -144,7 +166,18 @@ export default function getPlatformScopeFactory( } info.version = version; - info.protocolVersion = parseInt(tenderdashStatus.node_info.protocol_version.app, 10); + // Tenderdash GET RPC responses are unwrapped (writeHTTPResponse sends + // the bare result). node_info.protocol_version.app is snapshotted at + // process start, so it is only a fallback for the live consensus value. + const activeProtocolVersion = parseProtocolVersion( + tenderdashConsensusParams?.consensus_params?.version?.app_version, + ); + const nodeInfoProtocolVersion = parseProtocolVersion( + tenderdashStatus.node_info.protocol_version.app, + ); + + info.protocolVersion = activeProtocolVersion ?? nodeInfoProtocolVersion; + // abci_info app_version reflects the installed software's desired/supported version. info.desiredProtocolVersion = tenderdashAbciInfo.response.app_version; info.listening = listening; info.latestBlockHeight = latestBlockHeight; diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index 14f0d88cffe..1a7f446ab4d 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -44,7 +44,6 @@ describe('getPlatformScopeFactory', () => { mockCreateRpcClient = () => mockRpcClient; mockDetermineDockerStatus = this.sinon.stub(determineStatus, 'docker'); mockMNOWatchProvider = this.sinon.stub(providers.mnowatch, 'checkPortStatus'); - // eslint-disable-next-line mockFetch = this.sinon.stub(globalThis, 'fetch'); mockGetConnectionHost = this.sinon.stub(); @@ -78,17 +77,21 @@ describe('getPlatformScopeFactory', () => { mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci version').resolves({ exitCode: 0, out: '1.4.1' }); mockMNOWatchProvider.returns(Promise.resolve('OPEN')); + // node_info app differs from consensus params so the expectation pins the live source const mockStatus = { node_info: { protocol_version: { p2p: '10', block: '14', - app: '3', + app: '11', }, version: '0', network: 'test', moniker: 'test', }, + application_info: { + version: '999', + }, sync_info: { catching_up: false, latest_app_hash: 'DEADBEEF', @@ -107,6 +110,13 @@ describe('getPlatformScopeFactory', () => { last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', }, }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: '3', + }, + }, + }; const expectedScope = { platformActivation: 'Activated (at height 1337)', @@ -149,7 +159,9 @@ describe('getPlatformScopeFactory', () => { .onSecondCall() .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }); + .resolves({ json: () => Promise.resolve(mockAbciInfo) }) + .onCall(3) + .resolves({ json: () => Promise.resolve(mockConsensusParams) }); mockMNOWatchProvider.returns(Promise.resolve('OPEN')); const scope = await getPlatformScope(config); @@ -157,6 +169,147 @@ describe('getPlatformScopeFactory', () => { expect(scope).to.deep.equal(expectedScope); }); + /** + * Stub a healthy synced node so the protocol-version tests only vary + * the version sources. + * + * @param {Object} options + * @param {string} options.nodeInfoApp - node_info.protocol_version.app (process-start snapshot) + * @param {number} options.abciAppVersion - abci_info app_version (installed/desired) + * @param {Object} [options.consensusParams] - /consensus_params response body + * @param {Error} [options.consensusParamsError] - reject the /consensus_params request instead + */ + function mockHealthyPlatform({ + nodeInfoApp, abciAppVersion, consensusParams, consensusParamsError, + }) { + mockDetermineDockerStatus.returns(DockerStatusEnum.running); + mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); + mockRpcClient.getBlockchainInfo.returns({ + result: { + softforks: { + mn_rr: { active: true, height: 1337 }, + }, + }, + }); + mockDockerCompose.isServiceRunning.returns(true); + mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci status').resolves({ exitCode: 0, out: '' }); + mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci version').resolves({ exitCode: 0, out: '1.4.1' }); + mockMNOWatchProvider.returns(Promise.resolve('OPEN')); + + const mockStatus = { + node_info: { + protocol_version: { + p2p: '10', + block: '14', + app: nodeInfoApp, + }, + version: '0', + network: 'test', + moniker: 'test', + }, + sync_info: { + catching_up: false, + latest_app_hash: 'DEADBEEF', + latest_block_height: 1, + latest_block_hash: 'DEADBEEF', + latest_block_time: 1337, + }, + }; + const mockNetInfo = { n_peers: 6, listening: true }; + const mockAbciInfo = { + response: { + version: '1.4.1', + app_version: abciAppVersion, + last_block_height: 90, + last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', + }, + }; + + mockFetch + .onFirstCall() + .resolves({ json: () => Promise.resolve(mockStatus) }) + .onSecondCall() + .resolves({ json: () => Promise.resolve(mockNetInfo) }) + .onThirdCall() + .resolves({ json: () => Promise.resolve(mockAbciInfo) }); + + if (consensusParamsError) { + mockFetch.onCall(3).rejects(consensusParamsError); + } else { + mockFetch.onCall(3).resolves({ json: () => Promise.resolve(consensusParams) }); + } + } + + it('should fall back to node_info app version when consensus params omit app_version', async () => { + mockHealthyPlatform({ + nodeInfoApp: '3', + abciAppVersion: 4, + consensusParams: { consensus_params: { block: { max_bytes: '2097152' } } }, + }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(3); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + + it('should fall back to node_info app version when consensus params app_version is not a plain integer', async () => { + // '3.5' would parseInt to 3 and mask the fallback; it must be rejected + // whole. node_info is 2 here so a leaked prefix-parse (3) fails the test. + mockHealthyPlatform({ + nodeInfoApp: '2', + abciAppVersion: 4, + consensusParams: { consensus_params: { version: { app_version: '3.5' } } }, + }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(2); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + + it('should fall back to node_info app version when consensus params app_version is not a primitive', async () => { + // [3] would coerce to '3' via String(); non-primitives must be rejected. + mockHealthyPlatform({ + nodeInfoApp: '2', + abciAppVersion: 4, + consensusParams: { consensus_params: { version: { app_version: [3] } } }, + }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(2); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + + it('should fall back to node_info app version when the consensus params request fails', async () => { + mockHealthyPlatform({ + nodeInfoApp: '3', + abciAppVersion: 4, + consensusParamsError: new Error('consensus_params endpoint unavailable'), + }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(3); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + + it('should keep active consensus protocol distinct from newer desired version during rollout', async () => { + // Mid-rollout (#4135): consensus already activated 11, the installed + // software supports 12, and node_info still snapshots pre-upgrade 10. + mockHealthyPlatform({ + nodeInfoApp: '10', + abciAppVersion: 12, + consensusParams: { consensus_params: { version: { app_version: '11' } } }, + }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(11); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(12); + }); + it('should return platform syncing when it is catching up', async () => { mockDetermineDockerStatus.returns(DockerStatusEnum.running); mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); @@ -182,6 +335,9 @@ describe('getPlatformScopeFactory', () => { network: 'test', moniker: 'test', }, + application_info: { + version: '3', + }, sync_info: { catching_up: true, latest_app_hash: 'DEADBEEF', @@ -200,6 +356,13 @@ describe('getPlatformScopeFactory', () => { last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', }, }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: '3', + }, + }, + }; const expectedScope = { platformActivation: 'Activated (at height 1337)', @@ -242,7 +405,9 @@ describe('getPlatformScopeFactory', () => { .onSecondCall() .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }); + .resolves({ json: () => Promise.resolve(mockAbciInfo) }) + .onCall(3) + .resolves({ json: () => Promise.resolve(mockConsensusParams) }); mockMNOWatchProvider.returns(Promise.resolve('OPEN')); const scope = await getPlatformScope(config); @@ -445,6 +610,9 @@ describe('getPlatformScopeFactory', () => { network: 'test', moniker: 'test', }, + application_info: { + version: '3', + }, sync_info: { catching_up: false, latest_app_hash: 'DEADBEEF', @@ -463,6 +631,13 @@ describe('getPlatformScopeFactory', () => { last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', }, }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: '3', + }, + }, + }; mockFetch .onFirstCall() @@ -470,7 +645,9 @@ describe('getPlatformScopeFactory', () => { .onSecondCall() .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }); + .resolves({ json: () => Promise.resolve(mockAbciInfo) }) + .onCall(3) + .resolves({ json: () => Promise.resolve(mockConsensusParams) }); const expectedScope = { platformActivation: 'Activated (at height 1337)',