Repository: hiero-ledger/solo
Pull request #2006: feat: fix remote configuration migration, add test workflow
Author: login: JeffreyDallas
Labels: name: released
Created: 2025-05-22T19:54:06Z | Merged: 2025-05-23T16:01:04Z

## Description

This pull request changes the following:

* move old local config from `~/.solo/cache/` to `~/.solo/` during migration
* Fix remote configuration migration: version string can not be empty, otherwise would fail validation after migration
* Fix remote configuration migration: migrated components should be list, not map
* Special handling of explorer label
* Special handing of old mirror chart version `v0.122`

### Related Issues

* Closes #1952
* Related to #

### Pull request (PR) checklist

* \[ ] This PR added tests (unit, integration, and/or end-to-end)
* \[ ] This PR updated documentation
* \[ ] This PR added no TODOs or commented out code
* \[x] This PR has no breaking changes
* \[ ] Any technical debt has been documented as a separate issue and linked to this PR
* \[ ] Any `package.json` changes have been explained to and approved by a repository manager
* \[x] All related issues have been linked to this PR
* \[x] All changes in this PR are included in the description
* \[x] When this PR merges the commits will be squashed and the title will be used as the commit message, the 'commit message guidelines' below have been followed

### Testing

* \[ ] This PR added unit tests
* \[ ] This PR added integration/end-to-end tests
* \[ ] These changes required manual testing that is documented below
* \[ ] Anything not tested is documented

The following manual testing was done:

* TBD

The following was not tested:

* TBD

<details>
<summary>
Commit message guidelines
</summary>
We use 'Conventional Commits' to ensure that our commit messages are easy to read, follow a consistent format, and for automated release note generation. Please follow the guidelines below when writing your commit messages:

1. BREAKING CHANGE: a commit that has a footer BREAKING CHANGE:, or appends a ! after the type/scope, introduces a breaking API change (correlating with MAJOR in Semantic Versioning). A BREAKING CHANGE can be part of commits of any type.  NOTE: currently breaking changes will only bump the MAJOR version.
2. The title is prefixed with one of the following:

| Prefix    | Description                                         | Semantic Version Update | Captured in Release Notes |
|-----------|-----------------------------------------------------|-------------------------|---------------------------|
| feat:     | a new feature                                       | MINOR                   | Yes                       |
| fix:      | a bug fix                                           | PATCH                   | Yes                       |
| perf:     | performance                                         | PATCH                   | Yes                       |
| refactor: | code change that isn't feature or fix               | none                    | No                        |
| test:     | adding missing tests                                | none                    | No                        |
| docs:     | changes to documentation                            | none                    | Yes                        |
| build:    | changes to build process                            | none                    | No                        |
| ci:       | changes to CI configuration                         | none                    | No                        |
| style:    | formatting, missing semi-colons, etc                | none                    | No                        |
| chore:    | updating grunt tasks etc; no production code change | none                    | No                        |

</details>


Linked issue #1952: create e2e test to ensure that the migration of the local config and remote config from v0.35.x to v0.36.0 is working correctly
We need to ensure that the path of going from v0.35.x to v0.36.0 is performing the migration logic on local config, remote config, and not causing any other issues.
- create a workflow job that will run a script, preferably a Taskfile, but bash would be acceptable that will install globally Solo from npmjs using latest (currently latest is v0.35.2), this will ensure we are always testing the upgrade path of latest to `main` (next version).
- after latest is installed, then run the happy path workflow to build a network with consensus node, mirror node, explorer, and relay
- then using the code from the current branch, run the solo commands that will upgrade the local config and remote config
- validate that the migration has occurred on the local config and remote config
- ensure that the workflow/job runs on PR checks as well as merge to main
Linked issue comments: user: login: JeffreyDallas
created_at: 2025-05-14T21:42:48Z
body: Currently found the v0.35.2 save local-config.yaml to `~/.solo/cache`
but latest code is trying to read from `~/.solo/`

in file `local-config-runtime-state.ts`, it first try it can find configuration, it could not ,
it create one, then it tried to load and migrate, which would read a newly created empty file

author_association: CONTRIBUTOR
user: login: jeromy-cannon
created_at: 2025-05-15T16:58:27Z
body: > Currently found the v0.35.2 save local-config.yaml to `~/.solo/cache` but latest code is trying to read from `~/.solo/`
> 
> in file `local-config-runtime-state.ts`, it first try it can find configuration, it could not , it create one, then it tried to load and migrate, which would read a newly created empty file

As part of the migration let us move it from the cache folder to the solo home directory `~/.solo`.  Maybe add some detection logic to read from solo home, and then if not found check solo cache with a TODO to retire once we no longer support v0.35.x migration.
author_association: CONTRIBUTOR
user: login: JeffreyDallas
created_at: 2025-05-15T17:49:43Z
body: Also need to add some logic to migrate remoteConfig, currently mssing.
And in class DeploymentState, all components are list, but the class RemoteConfigV1Migration create state component
as map, cause some migration error, need to fix
author_association: CONTRIBUTOR
user: login: JeffreyDallas
created_at: 2025-05-22T17:30:59Z
body: explorer label changed from "hedera-exploer" to "hiero-explorer"

so after migration, function `validateComponentGroup` of `remote-config-validator.ts` would
fail since it is expecting `hiero-explorer`, need to find a way to do special handling of old explorer version label validtion.

but the call back function has no version information
author_association: CONTRIBUTOR
user: login: swirlds-automation
created_at: 2025-05-23T22:37:49Z
body: :tada: This issue has been resolved in version 0.36.0 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/@hashgraph/solo/v/0.36.0)
- [GitHub release](https://github.com/hiero-ledger/solo/releases/tag/v0.36.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: CONTRIBUTOR

Diff:
diff --git a/.github/workflows/flow-migration-test.yaml b/.github/workflows/flow-migration-test.yaml
new file mode 100644
index 0000000000..fbae9b6cb2
--- /dev/null
+++ b/.github/workflows/flow-migration-test.yaml
@@ -0,0 +1,84 @@
+##
+# Copyright (C) 2023-2024 Hedera Hashgraph, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+name: "Test Migration Process"
+
+on:
+  workflow_dispatch:
+  workflow_call:
+  pull_request:
+    types:
+      - opened
+      - reopened
+      - synchronize
+defaults:
+  run:
+    shell: bash
+
+permissions:
+  contents: read
+  actions: read
+
+jobs:
+  test-migration-process:
+    timeout-minutes: 30
+    runs-on: hiero-solo-linux-large
+    strategy:
+      fail-fast: false
+      matrix:
+        release-tag: ["0.35.0", "0.35.1", "0.35.2"]
+    steps:
+      - name: Harden Runner
+        uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+        with:
+          egress-policy: audit
+
+      - name: Checkout Code
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+      - name: Setup Node
+        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+        with:
+          node-version: 20
+          cache: npm
+
+      - name: Setup Helm
+        uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
+        with:
+          version: "v3.12.3" # helm version
+
+      - name: Setup Kind
+        uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0
+        with:
+          install_only: true
+          node_image: kindest/node:v1.31.4@sha256:2cb39f7295fe7eafee0842b1052a599a4fb0f8bcf3f83d96c7f4864c357c6c30
+          version: v0.26.0
+          kubectl_version: v1.31.4
+          verbosity: 3
+          wait: 120s
+
+      - name: Launch Network with Release version ${{ matrix.release-tag }}
+        run: |
+          echo "Install yq..."
+          sudo add-apt-repository ppa:rmescandon/yq -y
+          sudo apt update
+          sudo apt install yq -y
+  
+          npm install
+          task build
+          npm install -g @hashgraph/solo@${{ matrix.release-tag }} --force
+          solo --version
+          .github/workflows/script/launch_network.sh ${{ matrix.release-tag }}
diff --git a/.github/workflows/script/launch_network.sh b/.github/workflows/script/launch_network.sh
new file mode 100755
index 0000000000..e78f3869ef
--- /dev/null
+++ b/.github/workflows/script/launch_network.sh
@@ -0,0 +1,75 @@
+#!/bin/bash
+set -eo pipefail
+
+releaseTag="${1}"
+if [[ -z "${releaseTag}" ]]; then
+  echo "Usage: $0 <releaseTag>"
+  exit 1
+fi
+
+# check if yq is installed
+if ! command -v yq &> /dev/null
+then
+    echo "yq could not be found, please install it first"
+    exit 1
+fi
+
+npm install -g @hashgraph/solo@"${releaseTag}" --force
+solo --version
+
+SOLO_CLUSTER_NAME=solo-e2e
+SOLO_NAMESPACE=solo-e2e
+SOLO_CLUSTER_SETUP_NAMESPACE=solo-setup
+SOLO_DEPLOYMENT=solo-e2e
+
+kind delete cluster -n "${SOLO_CLUSTER_NAME}"
+kind create cluster -n "${SOLO_CLUSTER_NAME}"
+
+rm -rf ~/.solo/*
+
+echo "Launch solo using released version ${releaseTag}"
+
+solo init
+solo cluster setup -s "${SOLO_CLUSTER_SETUP_NAMESPACE}"
+solo node keys --gossip-keys --tls-keys -i node1,node2
+solo deployment create -i node1,node2 -n "${SOLO_NAMESPACE}" --context kind-"${SOLO_CLUSTER_NAME}" --email john@doe.com --deployment-clusters kind-"${SOLO_CLUSTER_NAME}" --cluster-ref kind-${SOLO_CLUSTER_NAME} --deployment "${SOLO_DEPLOYMENT}"
+solo network deploy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+solo node setup -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+solo node start -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+solo mirror-node deploy  --deployment "${SOLO_DEPLOYMENT}"
+solo explorer deploy -s "${SOLO_CLUSTER_SETUP_NAMESPACE}" --deployment "${SOLO_DEPLOYMENT}" --cluster-ref kind-${SOLO_CLUSTER_NAME}
+solo relay deploy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+cp ~/.solo/cache/local-config.yaml ./local-config-before.yaml
+cat ./local-config-before.yaml
+kubectl get ConfigMap solo-remote-config -n ${SOLO_NAMESPACE} -o yaml | yq '.data' > remote-config-before.yaml
+cat remote-config-before.yaml
+
+# trigger migration
+npm run solo-test -- account create --deployment "${SOLO_DEPLOYMENT}"
+
+cp ~/.solo/local-config.yaml ./local-config-after.yaml
+cat ./local-config-after.yaml
+kubectl get ConfigMap solo-remote-config -n ${SOLO_NAMESPACE} -o yaml | yq '.data' > remote-config-after.yaml
+cat remote-config-after.yaml
+
+# check local-config-after.yaml should contains 'schemaVersion: 2'
+if ! grep -q "schemaVersion: 2" ./local-config-after.yaml; then
+  echo "schemaVersion: 2 not found in local-config-after.yaml"
+  exit 1
+fi
+
+# check remote-config-after.yaml should contains 'schemaVersion: 1'
+if ! grep -q "schemaVersion: 1" ./remote-config-after.yaml; then
+  echo "schemaVersion: 1 not found in remote-config-after.yaml"
+  exit 1
+fi
+
+# uninstall components using current version
+npm run solo-test -- explorer destroy --deployment "${SOLO_DEPLOYMENT}" --force
+npm run solo-test -- relay destroy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}" --cluster-ref kind-${SOLO_CLUSTER_NAME}
+npm run solo-test -- mirror-node destroy --deployment "${SOLO_DEPLOYMENT}" --force
+npm run solo-test -- node stop -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+npm run solo-test -- network destroy --deployment "${SOLO_DEPLOYMENT}" --force
diff --git a/src/business/runtime-state/config/local/local-config-runtime-state.ts b/src/business/runtime-state/config/local/local-config-runtime-state.ts
index 409cc5cf68..9520bf6718 100644
--- a/src/business/runtime-state/config/local/local-config-runtime-state.ts
+++ b/src/business/runtime-state/config/local/local-config-runtime-state.ts
@@ -13,7 +13,7 @@ import {LocalConfigSchema} from '../../../../data/schema/model/local/local-confi
 import {RefreshLocalConfigSourceError} from '../../../errors/refresh-local-config-source-error.js';
 import {WriteLocalConfigFileError} from '../../../errors/write-local-config-file-error.js';
 import {PathEx} from '../../../utils/path-ex.js';
-import fs from 'node:fs';
+import fs, {existsSync, mkdirSync} from 'node:fs';
 import {LocalConfig} from './local-config.js';
 
 @injectable()
@@ -31,6 +31,21 @@ export class LocalConfigRuntimeState {
     this.fileName = patchInject(fileName, InjectTokens.LocalConfigFileName, this.constructor.name);
     this.basePath = patchInject(basePath, InjectTokens.HomeDirectory, this.constructor.name);
 
+    // check if config from an old version exists under the cache directory
+    const oldConfigPath: string = PathEx.join(this.basePath, 'cache');
+    const oldConfigFile: string = PathEx.join(oldConfigPath, this.fileName);
+    const oldConfigFileExists: boolean = existsSync(oldConfigFile);
+
+    if (this.configFileExists() && oldConfigFileExists) {
+      // if both files exist, remove the old one
+      fs.rmSync(oldConfigFile);
+    } else if (existsSync(oldConfigFile)) {
+      // if only the old file exists, copy it to the new location
+      mkdirSync(this.basePath, {recursive: true});
+      fs.copyFileSync(oldConfigFile, PathEx.join(this.basePath, this.fileName));
+      fs.rmSync(oldConfigFile);
+    }
+
     this.backend = new YamlFileStorageBackend(this.basePath);
     this.objectMapper = new ClassToObjectMapper(ConfigKeyFormatter.instance());
     this.source = new LocalConfigSource(
diff --git a/src/business/runtime-state/config/remote/remote-config-runtime-state.ts b/src/business/runtime-state/config/remote/remote-config-runtime-state.ts
index 4aa53c869f..610ecb17f7 100644
--- a/src/business/runtime-state/config/remote/remote-config-runtime-state.ts
+++ b/src/business/runtime-state/config/remote/remote-config-runtime-state.ts
@@ -2,8 +2,6 @@
 
 import {inject, injectable} from 'tsyringe-neo';
 import {type ObjectMapper} from '../../../../data/mapper/api/object-mapper.js';
-import {ClassToObjectMapper} from '../../../../data/mapper/impl/class-to-object-mapper.js';
-import {ConfigKeyFormatter} from '../../../../data/key/config-key-formatter.js';
 import {ReadRemoteConfigBeforeLoadError} from '../../../errors/read-remote-config-before-load-error.js';
 import {WriteRemoteConfigBeforeLoadError} from '../../../errors/write-remote-config-before-load-error.js';
 import {RemoteConfigSource} from '../../../../data/configuration/impl/remote-config-source.js';
diff --git a/src/core/config/remote/remote-config-validator.ts b/src/core/config/remote/remote-config-validator.ts
index 827d995415..4fe1fe0f35 100644
--- a/src/core/config/remote/remote-config-validator.ts
+++ b/src/core/config/remote/remote-config-validator.ts
@@ -17,6 +17,9 @@ import {patchInject} from '../../dependency-injection/container-helper.js';
 import {InjectTokens} from '../../dependency-injection/inject-tokens.js';
 import {RemoteConfigValidatorApi} from './api/remote-config-validator-api.js';
 import {DeploymentStateSchema} from '../../../data/schema/model/remote/deployment-state-schema.js';
+import {ExplorerStateSchema} from '../../../data/schema/model/remote/state/explorer-state-schema.js';
+import {SemVer} from 'semver';
+import {EXPLORER_OLD_VERSION_BEFORE_LABEL_CHANGE} from '../../../../version.js';
 
 /**
  * Static class is used to validate that components in the remote config
@@ -147,7 +150,17 @@ export class RemoteConfigValidator implements RemoteConfigValidatorApi {
       }
 
       const context: Context = this.localConfig.configuration.clusterRefs.get(component.metadata.cluster)?.toString();
-      const labels: string[] = getLabelsCallback(component);
+
+      let labels: string[] = getLabelsCallback(component);
+
+      // special handling of explorer scheme since its label changed
+      if (component instanceof ExplorerStateSchema && component.version) {
+        const chartVersion: SemVer = new SemVer(component.version);
+        const oldVersion: SemVer = new SemVer(EXPLORER_OLD_VERSION_BEFORE_LABEL_CHANGE);
+        if (chartVersion.compare(oldVersion) <= 0) {
+          labels = [constants.OLD_SOLO_EXPLORER_LABEL];
+        }
+      }
 
       try {
         const pods: Pod[] = await this.k8Factory.getK8(context).pods().list(namespace, labels);
diff --git a/src/core/constants.ts b/src/core/constants.ts
index db7db62c90..16c6803c5e 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -74,6 +74,7 @@ export const EXPLORER_CHART_URL =
 export const EXPLORER_RELEASE_NAME = 'hiero-explorer';
 export const SOLO_RELAY_LABEL = 'app=hedera-json-rpc-relay';
 export const SOLO_EXPLORER_LABEL = 'app.kubernetes.io/component=hiero-explorer';
+export const OLD_SOLO_EXPLORER_LABEL = 'app.kubernetes.io/component=hedera-explorer';
 
 export const INGRESS_CONTROLLER_CHART_URL =
   process.env.INGRESS_CONTROLLER_CHART_URL ?? 'https://haproxy-ingress.github.io/charts';
diff --git a/src/data/schema/migration/impl/remote/remote-config-v1-migration.ts b/src/data/schema/migration/impl/remote/remote-config-v1-migration.ts
index 05936bdabb..3f3234a951 100644
--- a/src/data/schema/migration/impl/remote/remote-config-v1-migration.ts
+++ b/src/data/schema/migration/impl/remote/remote-config-v1-migration.ts
@@ -7,6 +7,8 @@ import {Version} from '../../../../../business/utils/version.js';
 import {IllegalArgumentError} from '../../../../../business/errors/illegal-argument-error.js';
 import {InvalidSchemaVersionError} from '../../api/invalid-schema-version-error.js';
 import {getSoloVersion} from '../../../../../../version.js';
+import {Templates} from '../../../../../core/templates.js';
+import {type NodeAlias} from '../../../../../types/aliases.js';
 
 export class RemoteConfigV1Migration implements SchemaMigration {
   public get range(): VersionRange<number> {
@@ -32,8 +34,16 @@ export class RemoteConfigV1Migration implements SchemaMigration {
       throw new InvalidSchemaVersionError(clone.schemaVersion, 0);
     }
 
+    // Initialize metadata if it doesn't exist
+    if (!clone.metadata) {
+      clone.metadata = {};
+    }
+
+    // remove old typo property
+    delete clone.metadata.lastUpdateBy; // changed to lastUpdatedBy
+
     // Preserve the original metadata and add lastUpdated information
-    const originalMetadata = clone.metadata || {};
+    const originalMetadata = clone.metadata;
     clone.metadata = {
       ...originalMetadata,
       lastUpdatedAt: new Date(),
@@ -43,17 +53,34 @@ export class RemoteConfigV1Migration implements SchemaMigration {
       },
     };
 
-    // pull the versions from the old config, if it isn't set, then it will be set to 0.0.0 until an upgrade for the component is performed
+    // pull the versions from the old config, if it isn't set or set as empty,
+    // then it will be set to 0.0.0 until an upgrade for the component is performed
+    // Normalize version strings by removing 'v' prefix if present
+    const normalizeVersion = (version: string | undefined): string => {
+      if (!version) {
+        return '0.0.0';
+      }
+      //for invalid version such v0.122  convert it to v0.122.0
+      if (version.split('.').length === 2) {
+        version = version + '.0';
+      }
+      return version.startsWith('v') ? version.slice(1) : version;
+    };
+
     clone.versions = {
-      cli: clone.metadata.soloVersion ?? getSoloVersion(),
-      chart: clone.metadata.soloChartVersion ?? '0.0.0',
-      consensusNode: clone.metadata.hederaPlatformVersion ?? '0.0.0',
-      mirrorNodeChart: clone.metadata.hederaMirrorNodeChartVersion ?? '0.0.0',
-      explorerChart: clone.metadata.hederaExplorerChartVersion ?? '0.0.0',
-      jsonRpcRelayChart: clone.metadata.hederaJsonRpcRelayChartVersion ?? '0.0.0',
+      cli: clone.metadata.soloVersion || getSoloVersion(),
+      chart: normalizeVersion(clone.metadata.soloChartVersion),
+      consensusNode: normalizeVersion(clone.metadata.hederaPlatformVersion),
+      mirrorNodeChart: normalizeVersion(clone.metadata.hederaMirrorNodeChartVersion),
+      explorerChart: normalizeVersion(clone.metadata.hederaExplorerChartVersion),
+      jsonRpcRelayChart: normalizeVersion(clone.metadata.hederaJsonRpcRelayChartVersion),
       blockNodeChart: '0.0.0',
     };
 
+    // need to keep track of the version of explorer chart since explorer label changed after
+    // some specific version.
+    const hederaExplorerChartVersion: string = clone.metadata.hederaExplorerChartVersion;
+
     // delete the old version structure
     delete clone.metadata.soloVersion;
     delete clone.metadata.soloChartVersion;
@@ -92,13 +119,13 @@ export class RemoteConfigV1Migration implements SchemaMigration {
     // migrate the components
     clone.state = {
       ledgerPhase: 'initialized',
-      consensusNodes: {},
-      blockNodes: {},
-      mirrorNodes: {},
-      relayNodes: {},
-      haProxies: {},
-      envoyProxies: {},
-      explorers: {},
+      consensusNodes: [],
+      blockNodes: [],
+      mirrorNodes: [],
+      relayNodes: [],
+      haProxies: [],
+      envoyProxies: [],
+      explorers: [],
     };
 
     // Ensure components exists to avoid errors
@@ -108,8 +135,8 @@ export class RemoteConfigV1Migration implements SchemaMigration {
         haProxies: {},
         envoyProxies: {},
         mirrorNodes: {},
-        relayNodes: {},
-        explorers: {},
+        relays: {},
+        mirrorNodeExplorers: {},
       };
     }
 
@@ -123,13 +150,15 @@ export class RemoteConfigV1Migration implements SchemaMigration {
           cluster: string;
         } = clone.components.consensusNodes[consensusNode];
 
-        clone.state.consensusNodes[consensusNode] = {
-          id: component.nodeId,
-          name: component.name,
-          namespace: component.namespace,
-          cluster: component.cluster,
-          phase: 'started',
-        };
+        clone.state.consensusNodes.push({
+          metadata: {
+            id: component.nodeId,
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
       }
     }
 
@@ -142,12 +171,15 @@ export class RemoteConfigV1Migration implements SchemaMigration {
           cluster: string;
         } = clone.components.haProxies[haproxy];
 
-        clone.state.haProxies[haproxy] = {
-          name: component.name,
-          namespace: component.namespace,
-          cluster: component.cluster,
-          phase: 'started',
-        };
+        clone.state.haProxies.push({
+          metadata: {
+            id: Templates.nodeIdFromNodeAlias(<NodeAlias>component.name),
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
       }
     }
 
@@ -160,30 +192,38 @@ export class RemoteConfigV1Migration implements SchemaMigration {
           cluster: string;
         } = clone.components.envoyProxies[envoyProxy];
 
-        clone.state.envoyProxies[envoyProxy] = {
-          name: component.name,
-          namespace: component.namespace,
-          cluster: component.cluster,
-          phase: 'started',
-        };
+        clone.state.envoyProxies.push({
+          metadata: {
+            id: Templates.nodeIdFromNodeAlias(<NodeAlias>component.name),
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
       }
     }
 
     // migrate explorers
-    if (clone.components.explorers) {
-      for (const explorer in clone.components.explorers) {
+    if (clone.components.mirrorNodeExplorers) {
+      for (const explorer in clone.components.mirrorNodeExplorers) {
         const component: {
           name: string;
+          nodeId: number;
           namespace: string;
           cluster: string;
-        } = clone.components.explorers[explorer];
-
-        clone.state.explorers[explorer] = {
-          name: component.name,
-          namespace: component.namespace,
-          cluster: component.cluster,
-          phase: 'started',
-        };
+        } = clone.components.mirrorNodeExplorers[explorer];
+
+        clone.state.explorers.push({
+          version: hederaExplorerChartVersion,
+          metadata: {
+            id: 0,
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
       }
     }
 
@@ -192,34 +232,64 @@ export class RemoteConfigV1Migration implements SchemaMigration {
       for (const mirrorNode in clone.components.mirrorNodes) {
         const component: {
           name: string;
+          nodeId: number;
           namespace: string;
           cluster: string;
         } = clone.components.mirrorNodes[mirrorNode];
 
-        clone.state.mirrorNodes[mirrorNode] = {
-          name: component.name,
-          namespace: component.namespace,
-          cluster: component.cluster,
-          phase: 'started',
-        };
+        clone.state.mirrorNodes.push({
+          metadata: {
+            id: 0,
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
       }
     }
 
     // migrate relay nodes
-    if (clone.components.relayNodes) {
-      for (const relayNode in clone.components.relayNodes) {
+    if (clone.components.relays) {
+      for (const relayNode in clone.components.relays) {
+        const component: {
+          consensusNodeAliases: string[];
+          name: string;
+          namespace: string;
+          cluster: string;
+        } = clone.components.relays[relayNode];
+
+        clone.state.relayNodes.push({
+          metadata: {
+            consensusNodeIds: component.consensusNodeAliases,
+            id: 0,
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
+      }
+    }
+
+    // migrate block node
+    if (clone.components.blockNodes) {
+      for (const blockNode in clone.components.blockNodes) {
         const component: {
           name: string;
           namespace: string;
           cluster: string;
-        } = clone.components.relayNodes[relayNode];
-
-        clone.state.relayNodes[relayNode] = {
-          name: component.name,
-          namespace: component.namespace,
-          cluster: component.cluster,
-          phase: 'started',
-        };
+        } = clone.components.blockNodes[blockNode];
+
+        clone.state.blockNodes.push({
+          metadata: {
+            id: Templates.nodeIdFromNodeAlias(<NodeAlias>component.name),
+            // name: component.name,
+            namespace: component.namespace,
+            cluster: component.cluster,
+            phase: 'started',
+          },
+        });
       }
     }
 
diff --git a/src/data/schema/model/remote/state/explorer-state-schema.ts b/src/data/schema/model/remote/state/explorer-state-schema.ts
index 87cf4beee5..fec2567851 100644
--- a/src/data/schema/model/remote/state/explorer-state-schema.ts
+++ b/src/data/schema/model/remote/state/explorer-state-schema.ts
@@ -1,7 +1,16 @@
 // SPDX-License-Identifier: Apache-2.0
 
-import {Exclude} from 'class-transformer';
+import {Exclude, Expose} from 'class-transformer';
 import {BaseStateSchema} from './base-state-schema.js';
+import {ComponentStateMetadataSchema} from './component-state-metadata-schema.js';
 
 @Exclude()
-export class ExplorerStateSchema extends BaseStateSchema {}
+export class ExplorerStateSchema extends BaseStateSchema {
+  @Expose()
+  public version: string;
+
+  public constructor(metadata?: ComponentStateMetadataSchema, version?: string) {
+    super(metadata);
+    this.version = version;
+  }
+}
diff --git a/test/unit/data/schema/migration/impl/remote/remote-config-v1-migration.test.ts b/test/unit/data/schema/migration/impl/remote/remote-config-v1-migration.test.ts
index 6f2ef360e7..df513fcc00 100644
--- a/test/unit/data/schema/migration/impl/remote/remote-config-v1-migration.test.ts
+++ b/test/unit/data/schema/migration/impl/remote/remote-config-v1-migration.test.ts
@@ -98,7 +98,19 @@ describe('RemoteConfigV1Migration', () => {
       // Verify state was created (components are migrated to state)
       expect(result).to.have.property('state');
       expect(result.state).to.have.property('consensusNodes');
-      expect(result.state.consensusNodes).to.be.an('object');
+      expect(result.state.consensusNodes).to.be.an('array');
+      expect(result.state).to.have.property('blockNodes');
+      expect(result.state.blockNodes).to.be.an('array');
+      expect(result.state).to.have.property('mirrorNodes');
+      expect(result.state.mirrorNodes).to.be.an('array');
+      expect(result.state).to.have.property('relayNodes');
+      expect(result.state.relayNodes).to.be.an('array');
+      expect(result.state).to.have.property('haProxies');
+      expect(result.state.haProxies).to.be.an('array');
+      expect(result.state).to.have.property('envoyProxies');
+      expect(result.state.envoyProxies).to.be.an('array');
+      expect(result.state).to.have.property('explorers');
+      expect(result.state.explorers).to.be.an('array');
 
       // Verify namespace and deploymentName were removed from metadata
       expect(result.metadata).to.not.have.property('namespace');
@@ -303,7 +315,7 @@ describe('RemoteConfigV1Migration', () => {
               cluster: 'cluster1',
             },
           },
-          explorers: {
+          mirrorNodeExplorers: {
             explorer1: {
               name: 'explorer1',
               namespace: 'namespace1',
@@ -317,11 +329,19 @@ describe('RemoteConfigV1Migration', () => {
               cluster: 'cluster1',
             },
           },
-          relayNodes: {
+          relays: {
             relay1: {
               name: 'relay1',
               namespace: 'namespace1',
               cluster: 'cluster1',
+              consensusNodeAliases: ['node1'],
+            },
+          },
+          blockNodes: {
+            block1: {
+              name: 'block1',
+              namespace: 'namespace1',
+              cluster: 'cluster1',
             },
           },
         },
@@ -333,55 +353,89 @@ describe('RemoteConfigV1Migration', () => {
       expect(result.state).to.have.property('ledgerPhase', 'initialized');
 
       // Check consensus nodes
-      expect(result.state.consensusNodes).to.have.property('node1');
-      expect(result.state.consensusNodes.node1).to.deep.include({
-        id: 1,
-        name: 'node1',
+      expect(Array.isArray(result.state.consensusNodes)).to.be.true;
+      expect(result.state.consensusNodes.length).to.equal(1);
+
+      // Check that id exists and other properties match
+      expect(result.state.consensusNodes[0].metadata).to.have.property('id');
+      expect(result.state.consensusNodes[0].metadata).to.include({
         namespace: 'namespace1',
         cluster: 'cluster1',
         phase: 'started',
       });
 
       // Check haProxies
-      expect(result.state.haProxies).to.have.property('haproxy1');
-      expect(result.state.haProxies.haproxy1).to.deep.include({
-        name: 'haproxy1',
+      expect(Array.isArray(result.state.haProxies)).to.be.true;
+      expect(result.state.haProxies.length).to.equal(1);
+
+      // Check that id exists and other properties match
+      expect(result.state.haProxies[0].metadata).to.have.property('id');
+      expect(result.state.haProxies[0].metadata).to.include({
         namespace: 'namespace1',
         cluster: 'cluster1',
         phase: 'started',
       });
 
       // Check envoyProxies
-      expect(result.state.envoyProxies).to.have.property('envoy1');
-      expect(result.state.envoyProxies.envoy1).to.deep.include({
-        name: 'envoy1',
+      expect(Array.isArray(result.state.envoyProxies)).to.be.true;
+      expect(result.state.envoyProxies.length).to.equal(1);
+
+      // Check that id exists and other properties match
+      expect(result.state.envoyProxies[0].metadata).to.have.property('id');
+      expect(result.state.envoyProxies[0].metadata).to.include({
         namespace: 'namespace1',
         cluster: 'cluster1',
         phase: 'started',
       });
 
       // Check explorers
-      expect(result.state.explorers).to.have.property('explorer1');
-      expect(result.state.explorers.explorer1).to.deep.include({
-        name: 'explorer1',
+      expect(Array.isArray(result.state.explorers)).to.be.true;
+      expect(result.state.explorers.length).to.equal(1);
+
+      // Check that version property exists
+      expect(result.state.explorers[0]).to.have.property('version');
+
+      // Check that id exists and other properties match
+      expect(result.state.explorers[0].metadata).to.have.property('id');
+      expect(result.state.explorers[0].metadata).to.include({
         namespace: 'namespace1',
         cluster: 'cluster1',
         phase: 'started',
       });
 
       // Check mirrorNodes
-      expect(result.state.mirrorNodes).to.have.property('mirror1');
-      expect(result.state.mirrorNodes.mirror1).to.deep.include({
-        name: 'mirror1',
+      expect(Array.isArray(result.state.mirrorNodes)).to.be.true;
+      expect(result.state.mirrorNodes.length).to.equal(1);
+
+      // Check that id exists and other properties match
+      expect(result.state.mirrorNodes[0].metadata).to.have.property('id');
+      expect(result.state.mirrorNodes[0].metadata).to.include({
         namespace: 'namespace1',
         cluster: 'cluster1',
         phase: 'started',
       });
 
       // Check relayNodes
-      expect(result.state.relayNodes).to.have.property('relay1');
-      expect(result.state.relayNodes.relay1).to.deep.include({
-        name: 'relay1',
+      expect(Array.isArray(result.state.relayNodes)).to.be.true;
+      expect(result.state.relayNodes.length).to.equal(1);
+
+      // Check that id and consensusNodeIds exist and other properties match
+      expect(result.state.relayNodes[0].metadata).to.have.property('id');
+      expect(result.state.relayNodes[0].metadata).to.have.property('consensusNodeIds');
+      expect(result.state.relayNodes[0].metadata.consensusNodeIds).to.deep.equal(['node1']);
+      expect(result.state.relayNodes[0].metadata).to.include({
+        namespace: 'namespace1',
+        cluster: 'cluster1',
+        phase: 'started',
+      });
+
+      // Check blockNodes
+      expect(Array.isArray(result.state.blockNodes)).to.be.true;
+      expect(result.state.blockNodes.length).to.equal(1);
+
+      // Check that id exists and other properties match
+      expect(result.state.blockNodes[0].metadata).to.have.property('id');
+      expect(result.state.blockNodes[0].metadata).to.include({
         namespace: 'namespace1',
         cluster: 'cluster1',
         phase: 'started',
diff --git a/version.ts b/version.ts
index 77854675a3..f1579c27e2 100644
--- a/version.ts
+++ b/version.ts
@@ -16,6 +16,7 @@ export const SOLO_CHART_VERSION: string = '0.52.0';
 export const HEDERA_PLATFORM_VERSION: string = 'v0.60.1';
 export const MIRROR_NODE_VERSION: string = 'v0.129.1';
 export const EXPLORER_VERSION: string = '24.15.0';
+export const EXPLORER_OLD_VERSION_BEFORE_LABEL_CHANGE = '24.12.0';
 export const HEDERA_JSON_RPC_RELAY_VERSION: string = 'v0.67.0';
 export const INGRESS_CONTROLLER_VERSION: string = '0.14.5';
 export const BLOCK_NODE_VERSION: string = '0.9.0';


Reviews:
user: login: jeromy-cannon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T08:26:13Z
user: login: JeffreyDallas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:51:19Z
user: login: JeffreyDallas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:51:23Z
user: login: jeromy-cannon
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T16:00:53Z

Review comments:
diff_hunk: @@ -0,0 +1,46 @@
+#!/bin/bash
+set -eo pipefail
+
+releaseTag="${1}"
+if [[ -z "${releaseTag}" ]]; then
+  echo "Usage: $0 <releaseTag>"
+  exit 1
+fi
+
+npm install -g @hashgraph/solo@"${releaseTag}" --force
+solo --version
+
+SOLO_CLUSTER_NAME=solo-e2e
+SOLO_NAMESPACE=solo-e2e
+SOLO_CLUSTER_SETUP_NAMESPACE=solo-setup
+SOLO_DEPLOYMENT=solo-e2e
+
+kind delete cluster -n "${SOLO_CLUSTER_NAME}"
+kind create cluster -n "${SOLO_CLUSTER_NAME}"
+
+rm -rf ~/.solo/*
+
+echo "Launch solo using released version ${releaseTag}"
+
+solo init
+solo cluster setup -s "${SOLO_CLUSTER_SETUP_NAMESPACE}"
+solo node keys --gossip-keys --tls-keys -i node1,node2
+solo deployment create -i node1,node2 -n "${SOLO_NAMESPACE}" --context kind-"${SOLO_CLUSTER_NAME}" --email john@doe.com --deployment-clusters kind-"${SOLO_CLUSTER_NAME}" --cluster-ref kind-${SOLO_CLUSTER_NAME} --deployment "${SOLO_DEPLOYMENT}"
+solo network deploy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+solo node setup -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+solo node start -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+solo mirror-node deploy  --deployment "${SOLO_DEPLOYMENT}"
+solo explorer deploy -s "${SOLO_CLUSTER_SETUP_NAMESPACE}" --deployment "${SOLO_DEPLOYMENT}" --cluster-ref kind-${SOLO_CLUSTER_NAME}
+solo relay deploy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+# trigger migration
+npm run solo-test -- account create --deployment "${SOLO_DEPLOYMENT}"
+
path: .github/workflows/script/launch_network.sh
user: login: jeromy-cannon
body: at this point can we do the following?
- display to sysout the local config yaml for investigation/manual checking
- check that the local config schema version matches our expectations
- kubectl down the remote config displaying it to sysout
- check that the remote config schema version matches our expectation
created_at: 2025-05-23T08:20:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,6 +31,15 @@ export class LocalConfigRuntimeState {
     this.fileName = patchInject(fileName, InjectTokens.LocalConfigFileName, this.constructor.name);
     this.basePath = patchInject(basePath, InjectTokens.HomeDirectory, this.constructor.name);
 
+    // check if config from an old version exists under the cache directory
+    const oldConfigPath: string = PathEx.join(this.basePath, 'cache');
+    const oldConfigFile: string = PathEx.join(oldConfigPath, this.fileName);
+    if (existsSync(oldConfigFile)) {
+      mkdirSync(this.basePath, {recursive: true});
+      fs.copyFileSync(oldConfigFile, PathEx.join(this.basePath, this.fileName));
path: src/business/runtime-state/config/local/local-config-runtime-state.ts
user: login: jeromy-cannon
body: if the old and new config file exist, then don't overwrite the new, just delete the old.
created_at: 2025-05-23T08:22:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,6 +31,15 @@ export class LocalConfigRuntimeState {
     this.fileName = patchInject(fileName, InjectTokens.LocalConfigFileName, this.constructor.name);
     this.basePath = patchInject(basePath, InjectTokens.HomeDirectory, this.constructor.name);
 
+    // check if config from an old version exists under the cache directory
+    const oldConfigPath: string = PathEx.join(this.basePath, 'cache');
+    const oldConfigFile: string = PathEx.join(oldConfigPath, this.fileName);
+    if (existsSync(oldConfigFile)) {
+      mkdirSync(this.basePath, {recursive: true});
+      fs.copyFileSync(oldConfigFile, PathEx.join(this.basePath, this.fileName));
path: src/business/runtime-state/config/local/local-config-runtime-state.ts
user: login: JeffreyDallas
body: Updated
created_at: 2025-05-23T14:51:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+#!/bin/bash
+set -eo pipefail
+
+releaseTag="${1}"
+if [[ -z "${releaseTag}" ]]; then
+  echo "Usage: $0 <releaseTag>"
+  exit 1
+fi
+
+npm install -g @hashgraph/solo@"${releaseTag}" --force
+solo --version
+
+SOLO_CLUSTER_NAME=solo-e2e
+SOLO_NAMESPACE=solo-e2e
+SOLO_CLUSTER_SETUP_NAMESPACE=solo-setup
+SOLO_DEPLOYMENT=solo-e2e
+
+kind delete cluster -n "${SOLO_CLUSTER_NAME}"
+kind create cluster -n "${SOLO_CLUSTER_NAME}"
+
+rm -rf ~/.solo/*
+
+echo "Launch solo using released version ${releaseTag}"
+
+solo init
+solo cluster setup -s "${SOLO_CLUSTER_SETUP_NAMESPACE}"
+solo node keys --gossip-keys --tls-keys -i node1,node2
+solo deployment create -i node1,node2 -n "${SOLO_NAMESPACE}" --context kind-"${SOLO_CLUSTER_NAME}" --email john@doe.com --deployment-clusters kind-"${SOLO_CLUSTER_NAME}" --cluster-ref kind-${SOLO_CLUSTER_NAME} --deployment "${SOLO_DEPLOYMENT}"
+solo network deploy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+solo node setup -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+solo node start -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+solo mirror-node deploy  --deployment "${SOLO_DEPLOYMENT}"
+solo explorer deploy -s "${SOLO_CLUSTER_SETUP_NAMESPACE}" --deployment "${SOLO_DEPLOYMENT}" --cluster-ref kind-${SOLO_CLUSTER_NAME}
+solo relay deploy -i node1,node2 --deployment "${SOLO_DEPLOYMENT}"
+
+# trigger migration
+npm run solo-test -- account create --deployment "${SOLO_DEPLOYMENT}"
+
path: .github/workflows/script/launch_network.sh
user: login: JeffreyDallas
body: Updated
created_at: 2025-05-23T14:51:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-22T19:56:24Z
body: ## Unit Test Results - Linux
32 tests  ±0   32 ✅ ±0   0s ⏱️ ±0s
16 suites ±0    0 💤 ±0 
 1 files   ±0    0 ❌ ±0 

Results for commit 040c6538. ± Comparison against base commit 23260803.

[test-results]:data:application/gzip;base64,H4sIAJiPMGgC/12MSQ7CMBAEvxL5zGG8xGA+Exkv0ogkRl5OEX/HSVgcblPVo1qIx9Elcu3oqSOpYN5BVrIl6oxhrgwV65LXjbMPDKkY82fu+Gj/B69xPAgXY4hvE8v8La73IbiLX2/jJrdxWzNhmjBXICDAyJ5f+M2dlQblBWXAPOOKGtC9595q4aQlzxdnlCRGAAEAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-22T19:57:28Z
body: ## Unit Test Results - Windows
  1 files  ±0   73 suites  ±0   2s ⏱️ -1s
279 tests ±0  279 ✅ ±0  0 💤 ±0  0 ❌ ±0 
283 runs  ±0  283 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 040c6538. ± Comparison against base commit 23260803.

[test-results]:data:application/gzip;base64,H4sIAOuPMGgC/13MSw7CIBSF4a00jB3wailuxiBwE2JbDI+Rce9eSa3o8PtPch4EwuIzOQ/sNJBcQ2lQAuVqMiXEDc2RuJT3xpX+6JKrtf/pFu6Y6BHAhOUn+JRi2kuqW/ucxY7jsivfx+busLn/s3FdQ0EQKqmdRjGLq1faUA2SccqBC80sNSMIcEb6yZHnC1acpVEEAQAA

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-23T01:06:11Z
body: ## E2E Test Report
 18 files  ±0  123 suites  ±0   2h 2m 39s ⏱️ + 2m 3s
323 tests ±0  323 ✅ ±0  0 💤 ±0  0 ❌ ±0 
338 runs  ±0  338 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit d6476957. ± Comparison against base commit 23260803.

[test-results]:data:application/gzip;base64,H4sIAADKL2gC/12MSw7CIBQAr9KwdsGv0OdlDAIvIbbFUFgZ7y5iU9HlzCTzIBhmv5HzwKbTQLYS8oe4qOhKMjnEtQotRqim1vzuovVGl61Y+69u4V4VPQSaMP8In1JMu0llbU8x7XAsO/M9Nu6GjfufjcsScgXilNQKRs0RkaJ0oFBQCZZxD8xejQJluJaUPF+mKCNKCQEAAA==

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-23T01:06:12Z
body: ## E2E Test Report
 18 files  ±0  123 suites  ±0   2h 2m 17s ⏱️ + 1m 41s
323 tests ±0  323 ✅ ±0  0 💤 ±0  0 ❌ ±0 
338 runs  ±0  338 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 040c6538. ± Comparison against base commit 23260803.

[test-results]:data:application/gzip;base64,H4sIAGqbMGgC/12MSw7CIBQAr9KwdvHg0QJexiCfhNgWQ2FlvLuITUWXM5PMg/gwu42cBypPA9lKyB9iWNGWpHOIaxUCUVRTa353bL3RZSvG/KtbuFcFh/A6zD/CpRTTblJZ2xPlDseyM99j427YuP+ZuCwhVyDAwUwjSrw6oTQozykD5hkqakCPHr3V3E2WPF/eVBJyCQEAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: codacy-production[bot]
created_at: 2025-05-23T01:06:19Z
body: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/hiero-ledger/solo/pull-requests/2006/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **+0.11%** (target: -1.00%) | :white_check_mark: **89.71%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (23260803f9ee87c8e39bbaec5501c9afaf0b850b) | 35703 | 16611 | 46.53% | |
| Head commit (040c65383be79a09f41202f2391c0a5f3fda4e6d) | 35810 (+107) | 16701 (+90) | 46.64% (**+0.11%**) |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#2006) | 175 | 157 | **89.71%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/hiero-ledger/solo/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/hiero-ledger/solo/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)

<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->
author_association: NONE
user: login: swirlds-automation
created_at: 2025-05-23T22:20:31Z
body: :tada: This PR is included in version 0.36.0 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/@hashgraph/solo/v/0.36.0)
- [GitHub release](https://github.com/hiero-ledger/solo/releases/tag/v0.36.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: CONTRIBUTOR