diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 71d1cd1a..8532cbe3 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -114,11 +114,11 @@ jobs: - name: Prepare Hiero Solo id: solo - uses: hiero-ledger/hiero-solo-action@6a1a77601cf3e69661fb6880530a4edf656b40d5 # v0.14.0 + uses: hiero-ledger/hiero-solo-action@fbca3e7a99ce9aa8a250563a81187abe115e0dad # v0.16.0 with: installMirrorNode: true mirrorNodeVersion: v0.142.0 - hieroVersion: v0.68.0 + hieroVersion: v0.68.4 - name: Run integration tests (HieroIntegrationTests) env: @@ -126,3 +126,78 @@ jobs: HIERO_OPERATOR_ID: 0.0.2 HIERO_OPERATOR_KEY: 302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137 run: swift test --filter HieroIntegrationTests + + test-dab: + name: Build and Test SDK with DAB + strategy: + matrix: + swift: ["5.9", "5.10"] + runs-on: hiero-client-sdk-linux-large + env: + SOLO_CLUSTER_NAME: solo + SOLO_NAMESPACE: solo + SOLO_CLUSTER_SETUP_NAMESPACE: solo-cluster + SOLO_DEPLOYMENT: solo-deployment + KIND_IMAGE: kindest/node:v1.32.2 # pin to a stable image + + steps: + - name: Harden Runner + uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f + with: + egress-policy: audit + + - name: Setup Swift + uses: SwiftyLab/setup-swift@4bbb093f8c68d1dee1caa8b67c681a3f8fe70a91 # v1.12.0 + with: + swift-version: ${{ matrix.swift }} + + - name: Checkout Code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Cache SPM build dir + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 + with: + path: .build + key: ${{ runner.os }}-${{ matrix.swift }}-spm-${{ github.job }}-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.swift }}-spm- + + - name: Install system dependencies for Swift/gRPC + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y \ + git curl jq coreutils ca-certificates \ + clang libicu-dev libxml2-dev libsqlite3-dev zlib1g-dev \ + libcurl4-openssl-dev libssl-dev pkg-config \ + protobuf-compiler + + - name: Verify Swift installation + run: | + swift --version + which swift + swift package --version + + - name: Build SDK + run: | + swift build + + - name: Prepare Hiero Solo + id: solo + uses: hiero-ledger/hiero-solo-action@fbca3e7a99ce9aa8a250563a81187abe115e0dad # v0.16.0 + with: + installMirrorNode: true + hieroVersion: v0.68.4 + mirrorNodeVersion: v0.142.0 + dualMode: true + + - name: Run DAB-related integration tests + env: + HIERO_PROFILE: ciIntegration + HIERO_OPERATOR_ID: 0.0.2 + HIERO_OPERATOR_KEY: 302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137 + HIERO_ENVIRONMENT_TYPE: custom + HIERO_CONSENSUS_NODES: 127.0.0.1:50211,127.0.0.1:51211 + HIERO_CONSENSUS_NODE_ACCOUNT_IDS: 0.0.3,0.0.4 + HIERO_MIRROR_NODES: 127.0.0.1:5600 + run: swift test --filter "DAB*" diff --git a/Sources/Hiero/Client/Client.swift b/Sources/Hiero/Client/Client.swift index f2204423..fbc28452 100644 --- a/Sources/Hiero/Client/Client.swift +++ b/Sources/Hiero/Client/Client.swift @@ -66,6 +66,9 @@ public final class Client: Sendable { /// Realm number for this client's network (backing storage) private let _realm: UInt64 + /// Flag to indicate if this client should use only plaintext endpoints (for integration testing) + private let _plaintextOnly: Bool + // MARK: - Initialization /// Primary designated initializer. @@ -85,7 +88,8 @@ public final class Client: Sendable { networkUpdatePeriod: UInt64? = TimeInterval(86400).nanoseconds, // 24 hours _ eventLoop: NIOCore.EventLoopGroup, shard: UInt64 = 0, - realm: UInt64 = 0 + realm: UInt64 = 0, + plaintextOnly: Bool = false ) { self.eventLoop = eventLoop self._consensusNetwork = .init(consensus) @@ -101,12 +105,14 @@ public final class Client: Sendable { mirrorNetwork: _mirrorNetwork, updatePeriod: networkUpdatePeriod, shard: shard, - realm: realm + realm: realm, + plaintext: plaintextOnly ) self._networkUpdatePeriod = .init(networkUpdatePeriod) self._backoff = .init(Backoff()) self._shard = shard self._realm = realm + self._plaintextOnly = plaintextOnly } // MARK: - Internal Accessors @@ -137,6 +143,26 @@ public final class Client: Sendable { return .fromTinybars(value) } + /// Whether this client should use only plaintext endpoints. + internal var plaintextOnly: Bool { + _plaintextOnly + } + + /// Shard number for this client's network (internal accessor) + internal var shard: UInt64 { + _shard + } + + /// Realm number for this client's network (internal accessor) + internal var realm: UInt64 { + _realm + } + + /// Internal accessor for the actual MirrorNetwork object (not just addresses) + internal var mirrorNetworkObject: MirrorNetwork { + _mirrorNetwork.load(ordering: .relaxed) + } + // MARK: - Public Accessors /// Returns the shard number for this client's network. @@ -246,7 +272,8 @@ public final class Client: Sendable { ledgerId: nil, eventLoop, shard: shard, - realm: realm + realm: realm, + plaintextOnly: true // forMirrorNetwork always uses plaintext endpoints ) let addressBook = try await NodeAddressBookQuery() diff --git a/Sources/Hiero/Client/ConsensusNetwork.swift b/Sources/Hiero/Client/ConsensusNetwork.swift index bf34897d..6f32a7b2 100644 --- a/Sources/Hiero/Client/ConsensusNetwork.swift +++ b/Sources/Hiero/Client/ConsensusNetwork.swift @@ -291,6 +291,14 @@ internal final class ConsensusNetwork: Sendable, AtomicReference { } } + /// Converts account IDs to their corresponding node indexes, skipping unknown account IDs. + /// + /// - Parameter accountIds: Array of account IDs to look up + /// - Returns: Array of node indexes for known account IDs only + internal func nodeIndexesForIdsAllowingUnknown(_ accountIds: [AccountId]) -> [Int] { + accountIds.compactMap { nodeIndexMap[$0] } + } + /// Returns indexes of all currently healthy nodes. internal func healthyNodeIndexes() -> [Int] { let now = Timestamp.now diff --git a/Sources/Hiero/Client/NetworkUpdateTask.swift b/Sources/Hiero/Client/NetworkUpdateTask.swift index a84c8489..0463e2d0 100644 --- a/Sources/Hiero/Client/NetworkUpdateTask.swift +++ b/Sources/Hiero/Client/NetworkUpdateTask.swift @@ -43,6 +43,9 @@ internal actor NetworkUpdateTask { /// Atomic reference to the mirror network for address book queries private let mirrorNetwork: ManagedAtomic + /// Whether to use only plaintext endpoints + private let plaintext: Bool + /// The background task performing periodic updates private var updateTask: Task<(), Error>? @@ -63,11 +66,13 @@ internal actor NetworkUpdateTask { mirrorNetwork: ManagedAtomic, updatePeriod: UInt64?, shard: UInt64, - realm: UInt64 + realm: UInt64, + plaintext: Bool = false ) { self.consensusNetwork = consensusNetwork self.mirrorNetwork = mirrorNetwork self.eventLoop = eventLoop + self.plaintext = plaintext if let updatePeriod { updateTask = Self.makeTask( @@ -78,7 +83,8 @@ internal actor NetworkUpdateTask { startDelay: Self.networkFirstUpdateDelay, updatePeriod: updatePeriod, shard: shard, - realm: realm + realm: realm, + plaintext: plaintext ) ) } @@ -104,7 +110,8 @@ internal actor NetworkUpdateTask { startDelay: nil, updatePeriod: updatePeriod, shard: shard, - realm: realm + realm: realm, + plaintext: plaintext ) ) } @@ -137,6 +144,9 @@ internal actor NetworkUpdateTask { /// Realm number for address book file ID internal let realm: UInt64 + + /// Whether to use only plaintext endpoints + internal let plaintext: Bool } // MARK: - Private Methods @@ -168,15 +178,34 @@ internal actor NetworkUpdateTask { FileId.getAddressBookFileIdFor(shard: config.shard, realm: config.realm) ).executeChannel(mirror.channel) - // Apply updates to consensus network atomically - let newNetwork = config.consensusNetwork.readCopyUpdate { old in - ConsensusNetwork.withAddressBook(old, eventLoop: config.eventLoop.next(), addressBook) + // Filter to plaintext-only endpoints if this is a plaintext-only client + let filtered: NodeAddressBook + if config.plaintext { + filtered = NodeAddressBook( + nodeAddresses: addressBook.nodeAddresses.map { address in + let plaintextEndpoints = address.serviceEndpoints.filter { endpoint in + endpoint.port == NodeConnection.consensusPlaintextPort + } + + return NodeAddress( + nodeId: address.nodeId, + rsaPublicKey: address.rsaPublicKey, + nodeAccountId: address.nodeAccountId, + tlsCertificateHash: address.tlsCertificateHash, + serviceEndpoints: plaintextEndpoints, + description: address.description) + } + ) + } else { + filtered = addressBook + } + + _ = config.consensusNetwork.readCopyUpdate { network in + ConsensusNetwork.withAddressBook(network, eventLoop: config.eventLoop.next(), filtered) } // Log successful update with structured format - print( - "[Hiero.NetworkUpdate] Consensus network updated successfully" - ) + print("[Hiero.NetworkUpdate] Consensus network updated successfully") } catch let error as HError { // Log error with structured format and context diff --git a/Sources/Hiero/Execute.swift b/Sources/Hiero/Execute.swift index b261d8ae..61cdb191 100644 --- a/Sources/Hiero/Execute.swift +++ b/Sources/Hiero/Execute.swift @@ -3,6 +3,7 @@ import Foundation import GRPC import HieroProtobufs +import NIOCore import SwiftProtobuf // MARK: - Execute Protocol @@ -146,6 +147,10 @@ private struct ExecuteContext { /// Timeout for a single GRPC request (currently unused) let grpcTimeout: Duration? + + /// Closure to update network from address book (for handling INVALID_NODE_ACCOUNT_ID) + + let updateNetworkFromAddressBook: (() async throws -> Void)? } // MARK: - Public Execute Functions @@ -204,7 +209,37 @@ internal func executeAny( network: client.consensus, backoffConfig: backoffBuilder, maxAttempts: backoff.maxAttempts, - grpcTimeout: nil + grpcTimeout: nil as Duration?, + updateNetworkFromAddressBook: { + let addressBook = try await NodeAddressBookQuery() + .setFileId(FileId.getAddressBookFileIdFor(shard: client.shard, realm: client.realm)) + .executeChannel(client.mirrorNetworkObject.channel) + + // Filter to plaintext-only endpoints if this is a plaintext-only client (e.g., forMirrorNetwork) + // Otherwise, use the full address book (Network.withAddressBook will prefer TLS, then fall back to plaintext) + let filtered: NodeAddressBook + if client.plaintextOnly { + filtered = NodeAddressBook( + nodeAddresses: addressBook.nodeAddresses.map { address in + let plaintextEndpoints = address.serviceEndpoints.filter { endpoint in + endpoint.port == NodeConnection.consensusPlaintextPort + } + + return NodeAddress( + nodeId: address.nodeId, + rsaPublicKey: address.rsaPublicKey, + nodeAccountId: address.nodeAccountId, + tlsCertificateHash: address.tlsCertificateHash, + serviceEndpoints: plaintextEndpoints, + description: address.description) + } + ) + } else { + filtered = addressBook + } + + client.setNetworkFromAddressBook(filtered) + } ), executable: executable) } @@ -238,7 +273,10 @@ private func executeAnyInner( operatorAccountId: ctx.operatorAccountId ) - let explicitNodeIndexes = try executable.nodeAccountIds.map { try ctx.network.nodeIndexes(for: $0) } + let explicitNodeIndexes: [Int]? = executable.nodeAccountIds.flatMap { nodeAccountIds in + let indexes = ctx.network.nodeIndexesForIdsAllowingUnknown(nodeAccountIds) + return indexes.isEmpty ? nil : indexes + } var attempt = 0 while true { @@ -323,6 +361,9 @@ private struct PrecheckParameters { /// Account ID of the node that processed the request internal let nodeAccountId: AccountId + /// Index of the node that processed the request + internal let nodeIndex: Int + /// Transaction ID used for the request (if applicable) internal let transactionId: TransactionId? @@ -418,12 +459,13 @@ private func executeOnNode( let rawPrecheckStatus = try E.responsePrecheckStatus(response) let precheckStatus = Status(rawValue: rawPrecheckStatus) - return try handlePrecheckStatus( + return try await handlePrecheckStatus( params: PrecheckParameters( status: precheckStatus, response: response, context: context, nodeAccountId: nodeAccountId, + nodeIndex: nodeIndex, transactionId: transactionId, executable: executable, ctx: ctx @@ -475,7 +517,8 @@ private func handleGrpcError( /// - Parameter params: Parameters containing status, response, context, and execution state /// - Returns: Execution result indicating success or retry strategy /// - Throws: HError for unrecoverable status codes -private func handlePrecheckStatus(params: PrecheckParameters) throws -> ExecutionResult { +private func handlePrecheckStatus(params: PrecheckParameters) async throws -> ExecutionResult +{ switch params.status { case .ok where params.executable.shouldRetry(forResponse: params.response): return .retryWithBackoff(params.executable.makeErrorPrecheck(params.status, params.transactionId)) @@ -488,6 +531,23 @@ private func handlePrecheckStatus(params: PrecheckParameters) thr case .busy, .platformNotActive: return .retryImmediately(params.executable.makeErrorPrecheck(params.status, params.transactionId)) + case .invalidNodeAccount: + // Per HIP-1299: When INVALID_NODE_ACCOUNT is received, mark the node as unhealthy + // and query the address book to update the network with the correct node account IDs + params.ctx.network.markNodeUnhealthy(at: params.nodeIndex) + + // Update network from address book if the update function is available + if let updateNetwork = params.ctx.updateNetworkFromAddressBook { + do { + try await updateNetwork() + } catch { + // If address book query fails, log but continue with retry + // The node will remain marked as unhealthy and will retry + } + } + + return .retryImmediately(params.executable.makeErrorPrecheck(params.status, params.transactionId)) + case .transactionExpired where params.executable.explicitTransactionId == nil && params.ctx.operatorAccountId != nil: return .regenerateTransactionAndRetry(params.executable.makeErrorPrecheck(params.status, params.transactionId)) @@ -594,7 +654,8 @@ private struct NodeIndexSequence: AsyncSequence, AsyncIteratorProtocol { network: context.network, backoffConfig: context.backoffConfig, maxAttempts: context.maxAttempts, - grpcTimeout: context.grpcTimeout + grpcTimeout: context.grpcTimeout, + updateNetworkFromAddressBook: nil ), executable: request ) @@ -626,7 +687,14 @@ private func randomNodeIndexes(ctx: ExecuteContext, explicitNodeIndexes: [Int]?) ? nodeIndexes.count : (nodeIndexes.count + 2) / 3 // Integer division: (n+2)/3 rounds up properly - let randomNodeIndexes = randomIndexes(upTo: nodeIndexes.count, amount: nodeSampleAmount).map { nodeIndexes[$0] } + // When explicit nodes are provided, use them in order (don't randomize) + // This allows testing scenarios like INVALID_NODE_ACCOUNT where you want to try nodes sequentially + let selectedIndexes: [Int] + if explicitNodeIndexes != nil { + selectedIndexes = nodeIndexes + } else { + selectedIndexes = randomIndexes(upTo: nodeIndexes.count, amount: nodeSampleAmount).map { nodeIndexes[$0] } + } - return NodeIndexSequence(indexes: randomNodeIndexes, passthrough: explicitNodeIndexes != nil, ctx: ctx) + return NodeIndexSequence(indexes: selectedIndexes, passthrough: explicitNodeIndexes != nil, ctx: ctx) } diff --git a/Tests/HieroIntegrationTests/NodeUpdateTransactionIntegrationTests.swift b/Tests/HieroIntegrationTests/NodeUpdateTransactionIntegrationTests.swift new file mode 100644 index 00000000..5a1478d0 --- /dev/null +++ b/Tests/HieroIntegrationTests/NodeUpdateTransactionIntegrationTests.swift @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 + +import Hiero +import HieroTestSupport +import XCTest + +internal final class NodeUpdateTransactionIntegrationTests: HieroIntegrationTestCase { + + internal func test_DAB_NodeUpdateTransactionCanExecute() async throws { + // Given / When + let receipt = try await NodeUpdateTransaction() + .nodeId(0) + .description("testUpdated") + .declineRewards(true) + .grpcWebProxyEndpoint(Endpoint(port: 123456, domainName: "testWebUpdated.com")) + .execute(testEnv.client) + .getReceipt(testEnv.client) + + // Then + XCTAssertEqual(receipt.nodeId, 0) + } + + internal func test_DAB_NodeUpdateTransactionCanChangeNodeAccountIdToTheSameAccount() async throws { + // Given / When + let receipt = try await NodeUpdateTransaction() + .nodeId(0) + .description("testUpdated") + .accountId(AccountId(num: 3)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + + // Then + XCTAssertEqual(receipt.status, .success) + } + + internal func test_DAB_NodeUpdateTransactionCanChangeNodeAccountId() async throws { + // Given + let (accountId, accountKey) = try await createTestAccount(initialBalance: TestConstants.testMediumHbarBalance) + + // When + let receipt = try await NodeUpdateTransaction() + .nodeAccountIds([AccountId(num: 3)]) + .nodeId(0) + .accountId(accountId) + .freezeWith(testEnv.client) + .sign(accountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client) + + // Then + XCTAssertEqual(receipt.status, .success) + + if receipt.status == .success { + // Reset the node account ID to the original account ID + _ = try await NodeUpdateTransaction() + .nodeAccountIds([AccountId(num: 4)]) + .nodeId(0) + .accountId(AccountId(num: 3)) + .freezeWith(testEnv.client) + .sign(accountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client) + + XCTAssertEqual(receipt.status, .success, "Node update transaction failed to reset node account ID") + } + } + + internal func test_DAB_NodeUpdateTransactionCanChangeNodeAccountIdInvalidSignature() async throws { + // Given + let (newOperatorAccountId, newOperatorKey) = try await createTestAccount( + initialBalance: TestConstants.testMediumHbarBalance) + + // Change the operator to the new account + _ = testEnv.client.setOperator(newOperatorAccountId, newOperatorKey) + + addTeardownBlock { [self] in + _ = testEnv.client.setOperator(testEnv.operator.accountId, testEnv.operator.privateKey) + } + + // Attempt to update node account ID without proper signatures + await assertReceiptStatus( + try await NodeUpdateTransaction() + .nodeId(0) + .description("testUpdated") + .accountId(AccountId(num: 3)) + .execute(testEnv.client) + .getReceipt(testEnv.client), + .invalidSignature + ) + } + + internal func test_DAB_NodeUpdateTransactionCanChangeNodeAccountIdToNonExistentAccountId() async throws { + // Given / When / Then + await assertReceiptStatus( + try await NodeUpdateTransaction() + .nodeId(0) + .description("testUpdated") + .accountId(AccountId(num: 9_999_999)) + .freezeWith(testEnv.client) + .execute(testEnv.client) + .getReceipt(testEnv.client), + .invalidSignature + ) + } + + internal func test_DAB_NodeUpdateTransactionCanChangeNodeAccountIdToDeletedAccountId() async throws { + // Given + let (accountId, accountKey) = try await createSimpleUnmanagedAccount() + _ = try await AccountDeleteTransaction() + .accountId(accountId) + .transferAccountId(testEnv.operator.accountId) + .sign(accountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client) + + // When / Then + await assertReceiptStatus( + try await NodeUpdateTransaction() + .nodeId(0) + .description("testUpdated") + .accountId(accountId) + .freezeWith(testEnv.client) + .sign(accountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client), + .accountDeleted + ) + } + + internal func test_DAB_NodeUpdateTransactionCanChangeNodeAccountIdMissingAdminKeySignature() async throws { + // Given + let (newAccountId, newAccountKey) = try await createTestAccount() + let (nonAdminOperatorId, nonAdminOperatorKey) = try await createTestAccount( + initialBalance: TestConstants.testMediumHbarBalance) + + _ = testEnv.client.setOperator(nonAdminOperatorId, nonAdminOperatorKey) + + addTeardownBlock { [self] in + _ = testEnv.client.setOperator(testEnv.operator.accountId, testEnv.operator.privateKey) + } + + // When / Then + await assertReceiptStatus( + try await NodeUpdateTransaction() + .nodeId(0) + .description("testUpdated") + .accountId(newAccountId) + .freezeWith(testEnv.client) + .sign(newAccountKey) // Only sign with account key, not node admin key + .execute(testEnv.client) + .getReceipt(testEnv.client), + .invalidSignature + ) + } + + internal func test_DAB_NodeUpdateTransactionCannotRemoveAccountIdWithoutAdminKey() async throws { + // Given + let (newAccountId, _) = try await createTestAccount(initialBalance: TestConstants.testMediumHbarBalance) + let (newOperatorAccountId, newOperatorKey) = try await createTestAccount( + initialBalance: TestConstants.testMediumHbarBalance) + + _ = testEnv.client.setOperator(newOperatorAccountId, newOperatorKey) + + addTeardownBlock { [self] in + _ = testEnv.client.setOperator(testEnv.operator.accountId, testEnv.operator.privateKey) + } + + // When / Then + await assertReceiptStatus( + try await NodeUpdateTransaction() + .nodeId(0) + .accountId(newAccountId) + .execute(testEnv.client) + .getReceipt(testEnv.client), + .invalidSignature + ) + } + + internal func disabledTestNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() async throws { + // Given + let (newOperatorAccountId, newOperatorKey) = try await createTestAccount( + initialBalance: TestConstants.testMediumHbarBalance) + let (newAccountId, newAccountKey) = try await createTestAccount( + initialBalance: TestConstants.testMediumHbarBalance) + + _ = testEnv.client.setOperator(newOperatorAccountId, newOperatorKey) + + addTeardownBlock { [self] in + _ = testEnv.client.setOperator(testEnv.operator.accountId, testEnv.operator.privateKey) + } + + await testEnv.client.setNetworkUpdatePeriod(nanoseconds: nil) + + let addressBook = try await NodeAddressBookQuery() + .setFileId(FileId.addressBook) + .execute(testEnv.client) + + let node0 = addressBook.nodeAddresses.first(where: { $0.nodeId == 0 })! + let oldNode0AccountId = node0.nodeAccountId + + // When + let updateReceipt = try await NodeUpdateTransaction() + .nodeId(0) + .accountId(newAccountId) + .sign(newAccountKey) + .sign(testEnv.operator.privateKey) + .execute(testEnv.client) + .getReceipt(testEnv.client) + + // Then + XCTAssertEqual(updateReceipt.status, .success, "Node update transaction failed") + XCTAssertEqual(updateReceipt.nodeId, 0, "Node ID mismatch in receipt") + + // Wait for the node update to propagate to the mirror node + try await Task.sleep(nanoseconds: 10_000_000_000) + + // Attempt to create a new account using node 0's OLD account ID, then fallback to node 4 + // This should trigger INVALID_NODE_ACCOUNT, update the address book, then retry with node 4 + let newAccountId2 = try await AccountCreateTransaction() + .nodeAccountIds([oldNode0AccountId, AccountId(num: 4)]) // Try in order: node 0 first, then node 4 + .keyWithoutAlias(.single(testEnv.operator.privateKey.publicKey)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId + + XCTAssertNotNil(newAccountId2, "Failed to create account after INVALID_NODE_ACCOUNT retry") + + // Cleanup: Reset node 0 back to its original account ID + do { + _ = try await NodeUpdateTransaction() + .nodeAccountIds([AccountId(num: 4)]) // Use a known good node + .nodeId(0) + .accountId(oldNode0AccountId) + .execute(testEnv.client) + .getReceipt(testEnv.client) + } catch { + // Best effort cleanup - log but don't fail the test + print("Warning: Failed to reset node 0 account ID: \(error)") + } + } +} diff --git a/Tests/HieroTestSupport/Environment/DotenvLoader.swift b/Tests/HieroTestSupport/Environment/DotenvLoader.swift index 360566e1..2b5ef6a7 100644 --- a/Tests/HieroTestSupport/Environment/DotenvLoader.swift +++ b/Tests/HieroTestSupport/Environment/DotenvLoader.swift @@ -63,9 +63,11 @@ public class DotenvLoader { } } + EnvironmentVariables.printAllTestVariables() return } catch { print("Failed to load .env from \(envPath): \(error)") + EnvironmentVariables.printAllTestVariables() return } } @@ -78,6 +80,7 @@ public class DotenvLoader { } print("No .env file found, using environment variables directly") + EnvironmentVariables.printAllTestVariables() } private static func setEnvironmentVariable(from env: Environment, key: String) {