From 67e6cbf65d6373b3d624a9b8c5ac27f75687a059 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 15 Jul 2026 20:26:00 -0500 Subject: [PATCH 1/9] fix(dashmate): use live Tenderdash app version for protocol status Source Protocol Version from status.application_info.version instead of the process-start snapshot in node_info.protocol_version.app, while keeping Desired Protocol Version from abci_info.app_version. Adds a regression unit test covering a stale node_info app protocol with a newer live application_info version after in-process upgrade. Fixes #4135 --- .../dashmate/src/status/scopes/platform.js | 6 +- .../test/unit/status/scopes/platform.spec.js | 76 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index 467e8e4ce0e..04b0fb3fb55 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -144,7 +144,11 @@ export default function getPlatformScopeFactory( } info.version = version; - info.protocolVersion = parseInt(tenderdashStatus.node_info.protocol_version.app, 10); + // node_info.protocol_version.app is snapshotted at Tenderdash process start and + // stays stale across in-process protocol upgrades. application_info.version is + // the live active/current app protocol version. + info.protocolVersion = parseInt(tenderdashStatus.application_info.version, 10); + // 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..1fae37878a8 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -89,6 +89,9 @@ describe('getPlatformScopeFactory', () => { network: 'test', moniker: 'test', }, + application_info: { + version: '3', + }, sync_info: { catching_up: false, latest_app_hash: 'DEADBEEF', @@ -157,6 +160,73 @@ describe('getPlatformScopeFactory', () => { expect(scope).to.deep.equal(expectedScope); }); + it('should use live application_info version when node_info protocol version is stale', async () => { + 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: '4.0.0' }); + mockMNOWatchProvider.returns(Promise.resolve('OPEN')); + + // After an in-process protocol upgrade, Tenderdash keeps the process-start + // snapshot in node_info.protocol_version.app while application_info.version + // and abci_info.app_version report the live/desired values. + const mockStatus = { + node_info: { + protocol_version: { + p2p: '10', + block: '14', + app: '11', + }, + version: '1.6.0', + network: 'dash-mainnet-1', + moniker: 'evonode', + }, + application_info: { + version: '12', + }, + sync_info: { + catching_up: false, + latest_app_hash: 'DEADBEEF', + latest_block_height: 398435, + latest_block_hash: 'DEADBEEF', + latest_block_time: 1337, + }, + }; + const mockNetInfo = { n_peers: 6, listening: true }; + + const mockAbciInfo = { + response: { + version: '4.0.0', + app_version: 12, + last_block_height: 398435, + last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', + }, + }; + + mockFetch + .onFirstCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) + .onSecondCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) + .onThirdCall() + .resolves({ json: () => Promise.resolve(mockAbciInfo) }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(12); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(12); + expect(scope.tenderdash.version).to.equal('1.6.0'); + expect(scope.tenderdash.latestBlockHeight).to.equal(398435); + }); + 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 +252,9 @@ describe('getPlatformScopeFactory', () => { network: 'test', moniker: 'test', }, + application_info: { + version: '3', + }, sync_info: { catching_up: true, latest_app_hash: 'DEADBEEF', @@ -445,6 +518,9 @@ describe('getPlatformScopeFactory', () => { network: 'test', moniker: 'test', }, + application_info: { + version: '3', + }, sync_info: { catching_up: false, latest_app_hash: 'DEADBEEF', From 554262467c6781ff978fc5766a9a3d74aaa6ae05 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 15 Jul 2026 20:29:26 -0500 Subject: [PATCH 2/9] test(dashmate): fold protocol-version regression into happy-path fixture Prove protocolVersion prefers live application_info.version by setting a stale node_info.protocol_version.app in the existing success case instead of duplicating the full mock bootstrap. --- .../test/unit/status/scopes/platform.spec.js | 71 +------------------ 1 file changed, 3 insertions(+), 68 deletions(-) diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index 1fae37878a8..28610e56718 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -78,12 +78,14 @@ 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.protocol_version.app can be stale after in-process upgrades; + // protocolVersion must come from application_info.version (live). const mockStatus = { node_info: { protocol_version: { p2p: '10', block: '14', - app: '3', + app: '11', }, version: '0', network: 'test', @@ -160,73 +162,6 @@ describe('getPlatformScopeFactory', () => { expect(scope).to.deep.equal(expectedScope); }); - it('should use live application_info version when node_info protocol version is stale', async () => { - 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: '4.0.0' }); - mockMNOWatchProvider.returns(Promise.resolve('OPEN')); - - // After an in-process protocol upgrade, Tenderdash keeps the process-start - // snapshot in node_info.protocol_version.app while application_info.version - // and abci_info.app_version report the live/desired values. - const mockStatus = { - node_info: { - protocol_version: { - p2p: '10', - block: '14', - app: '11', - }, - version: '1.6.0', - network: 'dash-mainnet-1', - moniker: 'evonode', - }, - application_info: { - version: '12', - }, - sync_info: { - catching_up: false, - latest_app_hash: 'DEADBEEF', - latest_block_height: 398435, - latest_block_hash: 'DEADBEEF', - latest_block_time: 1337, - }, - }; - const mockNetInfo = { n_peers: 6, listening: true }; - - const mockAbciInfo = { - response: { - version: '4.0.0', - app_version: 12, - last_block_height: 398435, - last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', - }, - }; - - mockFetch - .onFirstCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) - .onSecondCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) - .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }); - - const scope = await getPlatformScope(config); - - expect(scope.tenderdash.protocolVersion).to.equal(12); - expect(scope.tenderdash.desiredProtocolVersion).to.equal(12); - expect(scope.tenderdash.version).to.equal('1.6.0'); - expect(scope.tenderdash.latestBlockHeight).to.equal(398435); - }); - it('should return platform syncing when it is catching up', async () => { mockDetermineDockerStatus.returns(DockerStatusEnum.running); mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); From 592b88281b0b2bd4eb83037479dad4a956f0ddf2 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 15 Jul 2026 20:55:26 -0500 Subject: [PATCH 3/9] fix(dashmate): fall back when Tenderdash omits application_info Prefer live application_info.version for protocol status. When application_info is omitted (Tenderdash json omitempty if ABCIInfo fails, or older responses), fall back to node_info.protocol_version.app so status does not TypeError and report the service as error. The live field still wins when present, even if node_info is stale. Adds a regression unit test for the missing application_info case. --- .../dashmate/src/status/scopes/platform.js | 12 ++-- .../test/unit/status/scopes/platform.spec.js | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index 04b0fb3fb55..3be7baf69f0 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -144,10 +144,14 @@ export default function getPlatformScopeFactory( } info.version = version; - // node_info.protocol_version.app is snapshotted at Tenderdash process start and - // stays stale across in-process protocol upgrades. application_info.version is - // the live active/current app protocol version. - info.protocolVersion = parseInt(tenderdashStatus.application_info.version, 10); + // Prefer live application_info.version (current after in-process upgrades). + // Tenderdash may omit application_info entirely (json omitempty when ABCIInfo + // fails, or older builds without the field). Fall back to + // node_info.protocol_version.app only then — that snapshot can be stale, so it + // must not win when the live field is present. + const appProtocolVersion = tenderdashStatus.application_info?.version + ?? tenderdashStatus.node_info.protocol_version.app; + info.protocolVersion = parseInt(appProtocolVersion, 10); // abci_info app_version reflects the installed software's desired/supported version. info.desiredProtocolVersion = tenderdashAbciInfo.response.app_version; info.listening = listening; diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index 28610e56718..4055330e1e1 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -162,6 +162,67 @@ describe('getPlatformScopeFactory', () => { expect(scope).to.deep.equal(expectedScope); }); + it('should fall back to node_info app protocol when application_info is absent', async () => { + 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')); + + // When application_info is omitted (omitempty / unavailable), status must still + // report a numeric protocolVersion from node_info.protocol_version.app. + const mockStatus = { + node_info: { + protocol_version: { + p2p: '10', + block: '14', + app: '3', + }, + 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: 4, + last_block_height: 90, + last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', + }, + }; + + mockFetch + .onFirstCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) + .onSecondCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) + .onThirdCall() + .resolves({ json: () => Promise.resolve(mockAbciInfo) }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.serviceStatus).to.equal(ServiceStatusEnum.up); + expect(scope.tenderdash.protocolVersion).to.equal(3); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + it('should return platform syncing when it is catching up', async () => { mockDetermineDockerStatus.returns(DockerStatusEnum.running); mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); From 6ec6a4c56c22fa26709fa42cced6c66f0ec9b4a1 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 27 Jul 2026 10:27:09 -0500 Subject: [PATCH 4/9] fix(dashmate): source active protocol from consensus params --- .../dashmate/src/status/scopes/platform.js | 51 +++- .../test/unit/status/scopes/platform.spec.js | 268 +++++++++++++++++- 2 files changed, 300 insertions(+), 19 deletions(-) diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index 3be7baf69f0..c87f1abd5d2 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -7,6 +7,31 @@ import determineStatus from '../determineStatus.js'; import ContainerIsNotPresentError from '../../docker/errors/ContainerIsNotPresentError.js'; import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; +function parseProtocolVersion(protocolVersion) { + if (protocolVersion === null || typeof protocolVersion === 'undefined') { + return null; + } + + const protocolVersionString = protocolVersion.toString().trim(); + + if (!/^\d+$/.test(protocolVersionString)) { + return null; + } + + const parsedProtocolVersion = Number(protocolVersionString); + + if (!Number.isSafeInteger(parsedProtocolVersion) || parsedProtocolVersion < 0) { + return null; + } + + return parsedProtocolVersion; +} + +function getConsensusParamsAppVersion(tenderdashConsensusParams) { + return tenderdashConsensusParams?.result?.consensus_params?.version?.app_version + ?? tenderdashConsensusParams?.consensus_params?.version?.app_version; +} + /** * @returns {getPlatformScopeFactory} * @param {DockerCompose} dockerCompose @@ -116,13 +141,21 @@ 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([ + const [ + tenderdashStatus, + tenderdashNetInfo, + tenderdashAbciInfo, + ] = await Promise.all([ tenderdashStatusResponse.json(), tenderdashNetInfoResponse.json(), tenderdashAbciInfoResponse.json(), @@ -144,14 +177,14 @@ export default function getPlatformScopeFactory( } info.version = version; - // Prefer live application_info.version (current after in-process upgrades). - // Tenderdash may omit application_info entirely (json omitempty when ABCIInfo - // fails, or older builds without the field). Fall back to - // node_info.protocol_version.app only then — that snapshot can be stale, so it - // must not win when the live field is present. - const appProtocolVersion = tenderdashStatus.application_info?.version - ?? tenderdashStatus.node_info.protocol_version.app; - info.protocolVersion = parseInt(appProtocolVersion, 10); + const activeProtocolVersion = parseProtocolVersion( + getConsensusParamsAppVersion(tenderdashConsensusParams), + ); + 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; diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index 4055330e1e1..5f6c9e251ae 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(); @@ -79,7 +78,7 @@ describe('getPlatformScopeFactory', () => { mockMNOWatchProvider.returns(Promise.resolve('OPEN')); // node_info.protocol_version.app can be stale after in-process upgrades; - // protocolVersion must come from application_info.version (live). + // protocolVersion must come from live consensus params. const mockStatus = { node_info: { protocol_version: { @@ -92,7 +91,7 @@ describe('getPlatformScopeFactory', () => { moniker: 'test', }, application_info: { - version: '3', + version: '999', }, sync_info: { catching_up: false, @@ -112,6 +111,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)', @@ -154,7 +160,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); @@ -162,7 +170,7 @@ describe('getPlatformScopeFactory', () => { expect(scope).to.deep.equal(expectedScope); }); - it('should fall back to node_info app protocol when application_info is absent', async () => { + it('should fall back to node_info app protocol when consensus params app version is absent', async () => { mockDetermineDockerStatus.returns(DockerStatusEnum.running); mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); mockRpcClient.getBlockchainInfo.returns({ @@ -177,8 +185,8 @@ describe('getPlatformScopeFactory', () => { mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci version').resolves({ exitCode: 0, out: '1.4.1' }); mockMNOWatchProvider.returns(Promise.resolve('OPEN')); - // When application_info is omitted (omitempty / unavailable), status must still - // report a numeric protocolVersion from node_info.protocol_version.app. + // Older Tenderdash builds may omit consensus_params.version.app_version. + // Status must still report a numeric protocolVersion from node_info.protocol_version.app. const mockStatus = { node_info: { protocol_version: { @@ -207,6 +215,14 @@ describe('getPlatformScopeFactory', () => { last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', }, }; + const mockConsensusParams = { + consensus_params: { + block: { + max_bytes: '2097152', + max_gas: '57631392000', + }, + }, + }; mockFetch .onFirstCall() @@ -214,7 +230,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 scope = await getPlatformScope(config); @@ -223,6 +241,218 @@ describe('getPlatformScopeFactory', () => { expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); }); + it('should fall back to node_info app protocol when consensus params app version is empty', async () => { + 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: '3', + }, + version: '0', + network: 'test', + moniker: 'test', + }, + application_info: { + version: '', + }, + 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: 4, + last_block_height: 90, + last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', + }, + }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: '', + }, + }, + }; + + mockFetch + .onFirstCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) + .onSecondCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) + .onThirdCall() + .resolves({ json: () => Promise.resolve(mockAbciInfo) }) + .onCall(3) + .resolves({ json: () => Promise.resolve(mockConsensusParams) }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(3); + expect(Number.isNaN(scope.tenderdash.protocolVersion)).to.equal(false); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + + it('should fall back to node_info app protocol when consensus params app version is malformed', async () => { + 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: '3', + }, + version: '0', + network: 'test', + moniker: 'test', + }, + application_info: { + version: '999', + }, + 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: 4, + last_block_height: 90, + last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', + }, + }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: 'not-a-number', + }, + }, + }; + + mockFetch + .onFirstCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) + .onSecondCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) + .onThirdCall() + .resolves({ json: () => Promise.resolve(mockAbciInfo) }) + .onCall(3) + .resolves({ json: () => Promise.resolve(mockConsensusParams) }); + + const scope = await getPlatformScope(config); + + expect(scope.tenderdash.protocolVersion).to.equal(3); + expect(Number.isNaN(scope.tenderdash.protocolVersion)).to.equal(false); + expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); + }); + + it('should keep active consensus protocol distinct from newer desired version during rollout', async () => { + 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.5.0' }); + mockMNOWatchProvider.returns(Promise.resolve('OPEN')); + + const mockStatus = { + node_info: { + protocol_version: { + p2p: '10', + block: '14', + app: '10', + }, + version: '1.6.0', + network: 'test', + moniker: 'test', + }, + application_info: { + version: '12', + }, + 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.5.0', + app_version: 12, + last_block_height: 90, + last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', + }, + }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: '11', + }, + }, + }; + + mockFetch + .onFirstCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) + .onSecondCall() + .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) + .onThirdCall() + .resolves({ json: () => Promise.resolve(mockAbciInfo) }) + .onCall(3) + .resolves({ json: () => Promise.resolve(mockConsensusParams) }); + + 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 } }); @@ -269,6 +499,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)', @@ -311,7 +548,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); @@ -535,6 +774,13 @@ describe('getPlatformScopeFactory', () => { last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', }, }; + const mockConsensusParams = { + consensus_params: { + version: { + app_version: '3', + }, + }, + }; mockFetch .onFirstCall() @@ -542,7 +788,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)', From 8cd712d3eb2d9c7c1a145d90499fa5c5ae6ba0c4 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 23 Aug 2026 18:06:40 -0500 Subject: [PATCH 5/9] test(dashmate): deduplicate protocol-version fallback tests Extract the shared healthy-platform scaffolding into a helper and keep one test per distinct code path: omitted app_version, non-numeric app_version, failed consensus_params request, and the rollout active/desired distinction. Drops the empty-string case, which exercised the same parse branch as the non-numeric one, and adds the previously uncovered request-failure fallback. --- .../test/unit/status/scopes/platform.spec.js | 254 ++++-------------- 1 file changed, 48 insertions(+), 206 deletions(-) diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index 5f6c9e251ae..dc9f62023c9 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -170,7 +170,19 @@ describe('getPlatformScopeFactory', () => { expect(scope).to.deep.equal(expectedScope); }); - it('should fall back to node_info app protocol when consensus params app version is absent', async () => { + /** + * 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({ @@ -185,14 +197,12 @@ describe('getPlatformScopeFactory', () => { mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci version').resolves({ exitCode: 0, out: '1.4.1' }); mockMNOWatchProvider.returns(Promise.resolve('OPEN')); - // Older Tenderdash builds may omit consensus_params.version.app_version. - // Status must still report a numeric protocolVersion from node_info.protocol_version.app. const mockStatus = { node_info: { protocol_version: { p2p: '10', block: '14', - app: '3', + app: nodeInfoApp, }, version: '0', network: 'test', @@ -210,242 +220,74 @@ describe('getPlatformScopeFactory', () => { const mockAbciInfo = { response: { version: '1.4.1', - app_version: 4, + app_version: abciAppVersion, last_block_height: 90, last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', }, }; - const mockConsensusParams = { - consensus_params: { - block: { - max_bytes: '2097152', - max_gas: '57631392000', - }, - }, - }; mockFetch .onFirstCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) + .resolves({ json: () => Promise.resolve(mockStatus) }) .onSecondCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) + .resolves({ json: () => Promise.resolve(mockNetInfo) }) .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }) - .onCall(3) - .resolves({ json: () => Promise.resolve(mockConsensusParams) }); + .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.serviceStatus).to.equal(ServiceStatusEnum.up); expect(scope.tenderdash.protocolVersion).to.equal(3); expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); }); - it('should fall back to node_info app protocol when consensus params app version is empty', async () => { - mockDetermineDockerStatus.returns(DockerStatusEnum.running); - mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); - mockRpcClient.getBlockchainInfo.returns({ - result: { - softforks: { - mn_rr: { active: true, height: 1337 }, - }, - }, + it('should fall back to node_info app version when consensus params app_version is not numeric', async () => { + mockHealthyPlatform({ + nodeInfoApp: '3', + abciAppVersion: 4, + consensusParams: { consensus_params: { version: { app_version: 'not-a-number' } } }, }); - 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: '3', - }, - version: '0', - network: 'test', - moniker: 'test', - }, - application_info: { - version: '', - }, - 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: 4, - last_block_height: 90, - last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', - }, - }; - const mockConsensusParams = { - consensus_params: { - version: { - app_version: '', - }, - }, - }; - - mockFetch - .onFirstCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) - .onSecondCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) - .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }) - .onCall(3) - .resolves({ json: () => Promise.resolve(mockConsensusParams) }); const scope = await getPlatformScope(config); expect(scope.tenderdash.protocolVersion).to.equal(3); - expect(Number.isNaN(scope.tenderdash.protocolVersion)).to.equal(false); expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); }); - it('should fall back to node_info app protocol when consensus params app version is malformed', async () => { - mockDetermineDockerStatus.returns(DockerStatusEnum.running); - mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); - mockRpcClient.getBlockchainInfo.returns({ - result: { - softforks: { - mn_rr: { active: true, height: 1337 }, - }, - }, + 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'), }); - 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: '3', - }, - version: '0', - network: 'test', - moniker: 'test', - }, - application_info: { - version: '999', - }, - 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: 4, - last_block_height: 90, - last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', - }, - }; - const mockConsensusParams = { - consensus_params: { - version: { - app_version: 'not-a-number', - }, - }, - }; - - mockFetch - .onFirstCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) - .onSecondCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) - .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }) - .onCall(3) - .resolves({ json: () => Promise.resolve(mockConsensusParams) }); const scope = await getPlatformScope(config); expect(scope.tenderdash.protocolVersion).to.equal(3); - expect(Number.isNaN(scope.tenderdash.protocolVersion)).to.equal(false); expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); }); it('should keep active consensus protocol distinct from newer desired version during rollout', async () => { - mockDetermineDockerStatus.returns(DockerStatusEnum.running); - mockRpcClient.mnsync.withArgs('status').returns({ result: { IsSynced: true } }); - mockRpcClient.getBlockchainInfo.returns({ - result: { - softforks: { - mn_rr: { active: true, height: 1337 }, - }, - }, + // 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' } } }, }); - 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.5.0' }); - mockMNOWatchProvider.returns(Promise.resolve('OPEN')); - - const mockStatus = { - node_info: { - protocol_version: { - p2p: '10', - block: '14', - app: '10', - }, - version: '1.6.0', - network: 'test', - moniker: 'test', - }, - application_info: { - version: '12', - }, - 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.5.0', - app_version: 12, - last_block_height: 90, - last_block_app_hash: 's0CySQxgRg96DrnJ7HCsql+k/Sk4JiT3y0psCaUI3TI=', - }, - }; - const mockConsensusParams = { - consensus_params: { - version: { - app_version: '11', - }, - }, - }; - - mockFetch - .onFirstCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockStatus) })) - .onSecondCall() - .returns(Promise.resolve({ json: () => Promise.resolve(mockNetInfo) })) - .onThirdCall() - .resolves({ json: () => Promise.resolve(mockAbciInfo) }) - .onCall(3) - .resolves({ json: () => Promise.resolve(mockConsensusParams) }); const scope = await getPlatformScope(config); From a5144b0107d87b38ddc91046daa9917f4815eda9 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 23 Aug 2026 18:14:03 -0500 Subject: [PATCH 6/9] refactor(dashmate): parse only the unwrapped Tenderdash RPC response shape Tenderdash GET URI requests return the bare result (writeHTTPResponse writes rsp.Result without the JSON-RPC envelope), which is why the rest of this file already reads node_info and abci_info responses unwrapped. Drop the dead result-wrapped branch and reduce parseProtocolVersion to the parseInt idiom the file already uses, returning null instead of NaN so the node_info fallback can engage. --- .../dashmate/src/status/scopes/platform.js | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index c87f1abd5d2..19f28ad6439 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -8,28 +8,9 @@ import ContainerIsNotPresentError from '../../docker/errors/ContainerIsNotPresen import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; function parseProtocolVersion(protocolVersion) { - if (protocolVersion === null || typeof protocolVersion === 'undefined') { - return null; - } - - const protocolVersionString = protocolVersion.toString().trim(); - - if (!/^\d+$/.test(protocolVersionString)) { - return null; - } - - const parsedProtocolVersion = Number(protocolVersionString); - - if (!Number.isSafeInteger(parsedProtocolVersion) || parsedProtocolVersion < 0) { - return null; - } - - return parsedProtocolVersion; -} + const parsedProtocolVersion = parseInt(protocolVersion, 10); -function getConsensusParamsAppVersion(tenderdashConsensusParams) { - return tenderdashConsensusParams?.result?.consensus_params?.version?.app_version - ?? tenderdashConsensusParams?.consensus_params?.version?.app_version; + return Number.isNaN(parsedProtocolVersion) ? null : parsedProtocolVersion; } /** @@ -177,8 +158,11 @@ export default function getPlatformScopeFactory( } info.version = version; + // 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( - getConsensusParamsAppVersion(tenderdashConsensusParams), + tenderdashConsensusParams?.consensus_params?.version?.app_version, ); const nodeInfoProtocolVersion = parseProtocolVersion( tenderdashStatus.node_info.protocol_version.app, From 6124e96ba6ada0b842f35e799fd4c946416a0324 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 24 Aug 2026 15:58:35 +0200 Subject: [PATCH 7/9] fix(dashmate): reject partially numeric protocol-version values parseInt accepts a numeric prefix, so values like 3.5 or 11garbage parsed to a non-null number and masked the intended node_info fallback. Require the whole value to be a plain decimal integer within the safe range, and pin the prefix case in the fallback test with a node_info value that differs from the prefix. --- .../dashmate/src/status/scopes/platform.js | 16 +++++++++++-- .../test/unit/status/scopes/platform.spec.js | 24 +++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index 19f28ad6439..ceb2855d91a 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -8,9 +8,21 @@ import ContainerIsNotPresentError from '../../docker/errors/ContainerIsNotPresen import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; function parseProtocolVersion(protocolVersion) { - const parsedProtocolVersion = parseInt(protocolVersion, 10); + // 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.isNaN(parsedProtocolVersion) ? null : parsedProtocolVersion; + return Number.isSafeInteger(parsedProtocolVersion) ? parsedProtocolVersion : null; } /** diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index dc9f62023c9..d96ccfb7f2d 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -254,16 +254,32 @@ describe('getPlatformScopeFactory', () => { expect(scope.tenderdash.desiredProtocolVersion).to.equal(4); }); - it('should fall back to node_info app version when consensus params app_version is not numeric', async () => { + 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: '3', + nodeInfoApp: '2', abciAppVersion: 4, - consensusParams: { consensus_params: { version: { app_version: 'not-a-number' } } }, + consensusParams: { consensus_params: { version: { app_version: '3.5' } } }, }); const scope = await getPlatformScope(config); - expect(scope.tenderdash.protocolVersion).to.equal(3); + 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); }); From 96314f7f3855bf845316fd075d8445ed2413d935 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 24 Aug 2026 16:14:04 +0200 Subject: [PATCH 8/9] style(dashmate): drop unrelated destructuring reformat The tenderdashStatus destructuring gained no elements; keep it on one line so the PR diff only contains behavioral changes. --- packages/dashmate/src/status/scopes/platform.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index ceb2855d91a..b2f44b8eac3 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -144,11 +144,7 @@ export default function getPlatformScopeFactory( .catch(() => null), ]); - const [ - tenderdashStatus, - tenderdashNetInfo, - tenderdashAbciInfo, - ] = await Promise.all([ + const [tenderdashStatus, tenderdashNetInfo, tenderdashAbciInfo] = await Promise.all([ tenderdashStatusResponse.json(), tenderdashNetInfoResponse.json(), tenderdashAbciInfoResponse.json(), From 0f55c13a674d6775ef43f8a220139ad408e6f501 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 24 Aug 2026 16:35:02 +0200 Subject: [PATCH 9/9] docs(dashmate): make protocol-version test comment local to the mock --- packages/dashmate/test/unit/status/scopes/platform.spec.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index d96ccfb7f2d..1a7f446ab4d 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -77,8 +77,7 @@ 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.protocol_version.app can be stale after in-process upgrades; - // protocolVersion must come from live consensus params. + // node_info app differs from consensus params so the expectation pins the live source const mockStatus = { node_info: { protocol_version: {