diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61b39435d..4f5320b94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -186,13 +186,14 @@ jobs: - name: Code Quality Checks run: ./gradlew qualityCheck :examples:qualityCheck --continue --rerun-tasks - - name: Run Unit and Integration Tests - env: - OPERATOR_KEY: "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" - OPERATOR_ID: "0.0.2" - HEDERA_NETWORK: "localhost" - run: | - ./gradlew -POPERATOR_ID=$OPERATOR_ID -POPERATOR_KEY=$OPERATOR_KEY -PHEDERA_NETWORK=$HEDERA_NETWORK :aggregation:testCodeCoverageReport + - name: Run Unit and Integration Tests (excluding NodeUpdateAccountIdIntegrationTest) + run: | + ./gradlew \ + -POPERATOR_ID=$OPERATOR_ID \ + -POPERATOR_KEY=$OPERATOR_KEY \ + -PHEDERA_NETWORK=$HEDERA_NETWORK \ + :aggregation:testCodeCoverageReport \ + --exclude-test "*NodeUpdateAccountIdIntegrationTest" - name: Upload coverage to Codecov if: ${{ github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }} @@ -200,6 +201,50 @@ jobs: with: files: gradle/aggregation/build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml + test-node-update-account-id: + name: NodeUpdateAccountIdIntegrationTest (dual solo) + runs-on: hiero-client-sdk-linux-medium + needs: + - build + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21.0.6" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Prepare Hiero Solo (DUAL MODE) + id: solo + uses: hiero-ledger/hiero-solo-action@a5fdd9282834d985ae6928d9920404e026333078 + with: + installMirrorNode: true + + - name: Build SDK + run: ./gradlew assemble + + - name: Run Only NodeUpdateAccountIdIntegrationTest + env: + OPERATOR_KEY: "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" + OPERATOR_ID: "0.0.2" + HEDERA_NETWORK: "localhost" + run: | + ./gradlew integrationTest --tests "*NodeUpdateAccountIdIntegrationTest" + + run-examples: name: Run Examples runs-on: hiero-client-sdk-linux-medium diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java index b26a6e95f..26034d387 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java @@ -1404,6 +1404,41 @@ public synchronized Client setNetworkUpdatePeriod(Duration networkUpdatePeriod) return this; } + /** + * Trigger an immediate address book update to refresh the client's network with the latest node information. + * This is useful when encountering INVALID_NODE_ACCOUNT_ID errors to ensure subsequent transactions + * use the correct node account IDs. + * + * @return {@code this} + */ + public synchronized Client updateNetworkFromAddressBook() { + try { + var fileId = FileId.getAddressBookFileIdFor(this.shard, this.realm); + + logger.debug("Fetching address book from file {}", fileId); + System.out.println("Fetching address book from file " + fileId); + + // Execute synchronously - no async complexity + var addressBook = new AddressBookQuery().setFileId(fileId).execute(this); // ← Synchronous! + + logger.debug("Received address book with {} nodes", addressBook.nodeAddresses.size()); + System.out.println("address book size: " + addressBook.nodeAddresses.size()); + + // Update the network + this.setNetworkFromAddressBook(addressBook); + + logger.info("Address book update completed successfully"); + System.out.println("Address book update completed successfully"); + + } catch (TimeoutException e) { + logger.warn("Failed to fetch address book: {}", e.getMessage()); + } catch (Exception e) { + logger.warn("Failed to update address book", e); + } + + return this; + } + public Logger getLogger() { return this.logger; } diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java index 0d0f138a7..6eca34aef 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java @@ -403,6 +403,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech if (node.channelFailedToConnect(timeoutTime)) { logger.trace("Failed to connect channel for node {} for request #{}", node.getAccountId(), attempt); lastException = grpcRequest.reactToConnectionFailure(); + advanceRequest(); // Advance to next node before retrying continue; } @@ -425,6 +426,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech if (response == null) { if (grpcRequest.shouldRetryExceptionally(lastException)) { + advanceRequest(); // Advance to next node before retrying continue; } else { throw new RuntimeException(lastException); @@ -433,11 +435,24 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech var status = mapResponseStatus(response); var executionState = getExecutionState(status, response); - grpcRequest.handleResponse(response, status, executionState); + grpcRequest.handleResponse(response, status, executionState, client); switch (executionState) { case SERVER_ERROR: lastException = grpcRequest.mapStatusException(); + advanceRequest(); // Advance to next node before retrying + + // Handle INVALID_NODE_ACCOUNT after advancing + if (status == Status.INVALID_NODE_ACCOUNT) { + logger.trace( + "Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}", + node.getAccountId(), + attempt); + // Schedule async address book update + client.updateNetworkFromAddressBook(); + // Mark this node as unhealthy + client.network.increaseBackoff(node); + } continue; case RETRY: // Response is not ready yet from server, need to wait. @@ -446,6 +461,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech currentTimeout = Duration.between(Instant.now(), timeoutTime); delay(Math.min(currentTimeout.toMillis(), grpcRequest.getDelay())); } + advanceRequest(); // Advance to next node before retrying continue; case REQUEST_ERROR: throw grpcRequest.mapStatusException(); @@ -597,7 +613,18 @@ void setNodesFromNodeAccountIds(Client client) { if (nodeAccountIds.size() == 1) { var nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0)); if (nodeProxies == null || nodeProxies.isEmpty()) { - throw new IllegalStateException("Account ID did not map to valid node in the client's network"); + logger.warn("Node {} not found in network, fetching latest address book", nodeAccountIds.get(0)); + + try { + client.updateNetworkFromAddressBook(); // Synchronous update + nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0)); + } catch (Exception e) { + logger.error("Failed to update address book", e); + } + + if (nodeProxies == null || nodeProxies.isEmpty()) { + throw new IllegalStateException("nodeProxies is null or empty"); + } } nodes.addAll(nodeProxies).shuffle(); @@ -679,10 +706,9 @@ Node getNodeForExecute(int attempt) { private ProtoRequestT getRequestForExecute() { var request = makeRequest(); - // advance the internal index - // non-free queries and transactions map to more than 1 actual transaction and this will cause - // the next invocation of makeRequest to return the _next_ transaction - advanceRequest(); + // NOTE: advanceRequest() is now called explicitly in the retry logic + // after we determine that a retry is needed + // where node advancement happens AFTER error detection, not before return request; } @@ -727,6 +753,7 @@ private void executeAsyncInternal( .thenAccept(connectionFailed -> { if (connectionFailed) { var connectionException = grpcRequest.reactToConnectionFailure(); + advanceRequest(); // Advance to next node before retrying executeAsyncInternal( client, attempt + 1, @@ -750,6 +777,7 @@ private void executeAsyncInternal( if (grpcRequest.shouldRetryExceptionally(error)) { // the transaction had a network failure reaching Hedera + advanceRequest(); // Advance to next node before retrying executeAsyncInternal( client, attempt + 1, @@ -767,10 +795,26 @@ private void executeAsyncInternal( var status = mapResponseStatus(response); var executionState = getExecutionState(status, response); - grpcRequest.handleResponse(response, status, executionState); + grpcRequest.handleResponse(response, status, executionState, client); switch (executionState) { case SERVER_ERROR: + advanceRequest(); // Advance to next node before retrying + + // Handle INVALID_NODE_ACCOUNT after advancing (matches Go SDK's + // executionStateRetryWithAnotherNode) + if (status == Status.INVALID_NODE_ACCOUNT) { + logger.trace( + "Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}", + grpcRequest.getNode().getAccountId(), + attempt); + // Schedule async address book update (matches Go's defer + // client._UpdateAddressBook()) + client.updateNetworkFromAddressBook(); + // Mark this node as unhealthy + client.network.increaseBackoff(grpcRequest.getNode()); + } + executeAsyncInternal( client, attempt + 1, @@ -779,6 +823,7 @@ private void executeAsyncInternal( Duration.between(Instant.now(), timeoutTime)); break; case RETRY: + advanceRequest(); // Advance to next node before retrying Delayer.delayFor( (attempt < maxAttempts) ? grpcRequest.getDelay() : 0, client.executor) @@ -869,6 +914,10 @@ ExecutionState getExecutionState(Status status, ResponseT response) { return ExecutionState.SERVER_ERROR; case BUSY: return ExecutionState.RETRY; + case INVALID_NODE_ACCOUNT: + return ExecutionState + .SERVER_ERROR; // immediately retry with next node without delay. This occurs when a node's + // account ID has changed case OK: return ExecutionState.SUCCESS; default: @@ -973,8 +1022,12 @@ O mapResponse() { return Executable.this.mapResponse(response, node.getAccountId(), request); } - void handleResponse(ResponseT response, Status status, ExecutionState executionState) { - node.decreaseBackoff(); + void handleResponse(ResponseT response, Status status, ExecutionState executionState, @Nullable Client client) { + // Note: For INVALID_NODE_ACCOUNT, we don't mark the node as unhealthy here + // because we need to do it AFTER advancing the request, to match Go SDK behavior + if (status != Status.INVALID_NODE_ACCOUNT) { + node.decreaseBackoff(); + } this.response = Executable.this.responseListener.apply(response); this.responseStatus = status; @@ -1001,11 +1054,18 @@ void handleResponse(ResponseT response, Status status, ExecutionState executionS responseStatus); verboseLog(node); } - case SERVER_ERROR -> logger.warn( - "Problem submitting request to node {} for attempt #{}, retry with new node: {}", - node.getAccountId(), - attempt, - responseStatus); + case SERVER_ERROR -> { + // Note: INVALID_NODE_ACCOUNT is handled after advanceRequest() in execute methods + // to match Go SDK's executionStateRetryWithAnotherNode behavior + if (status != Status.INVALID_NODE_ACCOUNT) { + logger.warn( + "Problem submitting request to node {} for attempt #{}, retry with new node: {}", + node.getAccountId(), + attempt, + responseStatus); + verboseLog(node); + } + } default -> {} } } diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java index 9e816b2d6..0848f0f03 100644 --- a/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -511,6 +512,9 @@ void shouldRetryReturnsCorrectStates() { .isEqualTo(ExecutionState.SERVER_ERROR); assertThat(tx.getExecutionState(Status.PLATFORM_NOT_ACTIVE, null)).isEqualTo(ExecutionState.SERVER_ERROR); assertThat(tx.getExecutionState(Status.BUSY, null)).isEqualTo(ExecutionState.RETRY); + // INVALID_NODE_ACCOUNT now returns SERVER_ERROR to match Go SDK's executionStateRetryWithAnotherNode + // which immediately retries with another node without delay + assertThat(tx.getExecutionState(Status.INVALID_NODE_ACCOUNT, null)).isEqualTo(ExecutionState.SERVER_ERROR); assertThat(tx.getExecutionState(Status.OK, null)).isEqualTo(ExecutionState.SUCCESS); assertThat(tx.getExecutionState(Status.ACCOUNT_DELETED, null)).isEqualTo(ExecutionState.REQUEST_ERROR); } @@ -526,6 +530,67 @@ void shouldSetMaxRetry() { assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> tx.setMaxRetry(0)); } + @Test + void shouldMarkNodeAsUnusableOnInvalidNodeAccountId() throws PrecheckStatusException, TimeoutException { + when(node3.isHealthy()).thenReturn(true); + when(node3.channelFailedToConnect()).thenReturn(false); + when(node4.isHealthy()).thenReturn(true); + when(node4.channelFailedToConnect()).thenReturn(false); + when(node5.isHealthy()).thenReturn(true); + when(node5.channelFailedToConnect()).thenReturn(false); + + var tx = new DummyTransaction() { + @Override + Status mapResponseStatus(com.hedera.hashgraph.sdk.proto.TransactionResponse response) { + return Status.INVALID_NODE_ACCOUNT; + } + }; + var nodeAccountIds = Arrays.asList(new AccountId(0, 0, 3), new AccountId(0, 0, 4), new AccountId(0, 0, 5)); + tx.setNodeAccountIds(nodeAccountIds); + + var txResp = com.hedera.hashgraph.sdk.proto.TransactionResponse.newBuilder() + .setNodeTransactionPrecheckCode(ResponseCodeEnum.INVALID_NODE_ACCOUNT) + .build(); + + tx.blockingUnaryCall = (grpcRequest) -> txResp; + + // This should retry with a different node due to INVALID_NODE_ACCOUNT + assertThatExceptionOfType(MaxAttemptsExceededException.class).isThrownBy(() -> tx.execute(client)); + + // Verify that increaseBackoff was called on the network for each node that returned INVALID_NODE_ACCOUNT + verify(network, atLeastOnce()).increaseBackoff(any(Node.class)); + } + + @Test + void shouldTriggerAddressBookUpdateOnInvalidNodeAccountId() throws PrecheckStatusException, TimeoutException { + when(node3.isHealthy()).thenReturn(true); + when(node3.channelFailedToConnect()).thenReturn(false); + + var tx = new DummyTransaction() { + @Override + Status mapResponseStatus(com.hedera.hashgraph.sdk.proto.TransactionResponse response) { + return Status.INVALID_NODE_ACCOUNT; + } + }; + var nodeAccountIds = Arrays.asList(new AccountId(0, 0, 3)); + tx.setNodeAccountIds(nodeAccountIds); + + var txResp = com.hedera.hashgraph.sdk.proto.TransactionResponse.newBuilder() + .setNodeTransactionPrecheckCode(ResponseCodeEnum.INVALID_NODE_ACCOUNT) + .build(); + + tx.blockingUnaryCall = (grpcRequest) -> txResp; + + // This should trigger address book update due to INVALID_NODE_ACCOUNT + assertThatExceptionOfType(MaxAttemptsExceededException.class).isThrownBy(() -> tx.execute(client)); + + // Verify that increaseBackoff was called (node marking) + verify(network, atLeastOnce()).increaseBackoff(any(Node.class)); + + // Note: We can't easily test the address book update in this unit test since it's async + // and involves network calls. The integration test would be more appropriate for that. + } + static class DummyTransaction> extends Executable< T, diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateAccountIdIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateAccountIdIntegrationTest.java new file mode 100644 index 000000000..0b6194aa9 --- /dev/null +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateAccountIdIntegrationTest.java @@ -0,0 +1,587 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.hedera.hashgraph.sdk.test.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hedera.hashgraph.sdk.AccountCreateTransaction; +import com.hedera.hashgraph.sdk.AccountDeleteTransaction; +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.Client; +import com.hedera.hashgraph.sdk.Hbar; +import com.hedera.hashgraph.sdk.NodeUpdateTransaction; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.ReceiptStatusException; +import com.hedera.hashgraph.sdk.Status; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Integration test for NodeUpdateTransaction functionality, specifically testing + * the Dynamic Address Book (DAB) enhancement for updating node account IDs. + * + * This test verifies the complete flow of updating a node's account ID with + * proper signatures from both the node admin key and the account ID key. + */ +class NodeUpdateAccountIdIntegrationTest { + + @Test + @Disabled + @DisplayName( + "Given a node with an existing account ID, when a NodeUpdateTransaction is submitted to change the account ID to a new valid account with signatures from both the node admin key and the account ID key, then the transaction succeeds") + void shouldSucceedWhenUpdatingNodeAccountIdWithProperSignatures() throws Exception { + // Set up the local network with 2 nodes + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .setMirrorNetwork(List.of("localhost:5600")) + .setTransportSecurity(false)) { + // Set the operator to be account 0.0.2 + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: A node with an existing account ID (0.0.3) + // First, create a new account that will be used as the new node account ID + var newAccountKey = PrivateKey.generateED25519(); + var newAccountCreateTransaction = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setInitialBalance(Hbar.from(10)) + .setMaxTransactionFee(Hbar.from(1)); + + var newAccountResponse = newAccountCreateTransaction.execute(client); + var newAccountReceipt = newAccountResponse.getReceipt(client); + var newAccountId = newAccountReceipt.accountId; + + // Create a node admin key + var nodeAdminKey = PrivateKey.generateED25519(); + + // When: A NodeUpdateTransaction is submitted to change the account ID + var nodeUpdateTransaction = new NodeUpdateTransaction() + .setNodeId(0) // Using node 0 as in the existing test + .setAccountId(newAccountId) + .setAdminKey(nodeAdminKey.getPublicKey()) + .setMaxTransactionFee(Hbar.from(10)) + .setTransactionMemo("Update node account ID for DAB testing"); + + // Sign with both the node admin key and the new account key + nodeUpdateTransaction.freezeWith(client).sign(nodeAdminKey).sign(newAccountKey); + + // Then: The transaction should succeed + assertThatCode(() -> { + var response = nodeUpdateTransaction.execute(client); + assertThat(response).isNotNull(); + + // Verify the transaction was successful by checking the receipt + var receipt = response.getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + }) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName( + "Given a node with an existing account ID, when a NodeUpdateTransaction is submitted to change the account ID to the same existing account ID with proper signatures, then the transaction succeeds") + void testNodeUpdateTransactionCanChangeToSameAccount() throws Exception { + // Set up the local network with 2 nodes + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = + Client.forNetwork(network).setTransportSecurity(false).setMirrorNetwork(List.of("localhost:5600"))) { + + // Set the operator to be account 0.0.2 + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: A node with an existing account ID (0.0.3) + // When: A NodeUpdateTransaction is submitted to change the account ID to the same account (0.0.3) + var resp = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(new AccountId(0, 0, 3)) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3))) + .execute(client); + + // Then: The transaction should succeed + var receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + } + } + + @Test + @DisplayName( + "Given a node whose account ID has been updated, when a transaction is submitted to that node with the old account ID after the NodeUpdateTransaction reaches consensus, then the signed transaction for this node fails with INVALID_NODE_ACCOUNT_ID and the SDK retries successfully with another node") + void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() throws Exception { + // Set the network + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = + Client.forNetwork(network).setTransportSecurity(false).setMirrorNetwork(List.of("localhost:5600"))) { + + // Set the operator to be account 0.0.2 + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Create the account that will be the node account id + var resp = new AccountCreateTransaction() + .setKey(originalOperatorKey.getPublicKey()) + .setInitialBalance(Hbar.from(1)) + .execute(client); + + var receipt = resp.setValidateStatus(true).getReceipt(client); + var newNodeAccountID = receipt.accountId; + assertThat(newNodeAccountID).isNotNull(); + + // Update node account id (0.0.3 -> newNodeAccountID) + resp = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(newNodeAccountID) + .execute(client); + + System.out.println("Transaction node: " + resp.nodeId); + System.out.println("Receipt query nodes: " + resp.getReceiptQuery().getNodeAccountIds()); + System.out.println("Client network: " + client.getNetwork()); + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Wait for mirror node to import data + Thread.sleep(10000); + + var newAccountKey = PrivateKey.generateED25519(); + + // Submit to node 3 and node 4 + // Node 3 will fail with INVALID_NODE_ACCOUNT (because it now uses newNodeAccountID) + // The SDK should automatically: + // 1. Detect INVALID_NODE_ACCOUNT error + // 2. Advance to next node + // 3. Update the address book asynchronously + // 4. Mark node 3 as unhealthy + // 5. Retry with node 4 which should succeed + resp = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3), new AccountId(0, 0, 4))) + .execute(client); + + // If we reach here without exception, the SDK successfully handled the error and retried + assertThat(resp).isNotNull(); + + // This transaction should succeed using the updated node account ID + resp = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setNodeAccountIds(List.of(newNodeAccountID)) + .execute(client); + + assertThat(resp).isNotNull(); + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Revert the node account id (newNodeAccountID -> 0.0.3) + resp = new NodeUpdateTransaction() + .setNodeId(0) + .setNodeAccountIds(List.of(newNodeAccountID)) + .setDescription("testUpdated") + .setAccountId(new AccountId(0, 0, 3)) + .execute(client); + + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + } + } + + @Test + @DisplayName( + "Given a node with an existing account ID, when a NodeUpdateTransaction is submitted to change the account ID to a new valid account with only the account ID key signature (missing node admin signature), then the transaction fails with INVALID_SIGNATURE") + void testNodeUpdateTransactionFailsWithInvalidSignatureWhenMissingNodeAdminSignature() throws Exception { + // Set up the local network with 2 nodes + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .setMirrorNetwork(List.of("localhost:5600")) + .setTransportSecurity(false)) { + // Set the operator to be account 0.0.2 initially to create a new account + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: Create a new operator account without node admin privileges + var newOperatorKey = PrivateKey.generateED25519(); + var newAccountResponse = new AccountCreateTransaction() + .setKey(newOperatorKey.getPublicKey()) + .setInitialBalance(Hbar.from(2)) + .execute(client); + + var newOperatorAccountId = newAccountResponse.getReceipt(client).accountId; + + // Switch to the new operator (who doesn't have node admin privileges) + client.setOperator(newOperatorAccountId, newOperatorKey); + + // When: A NodeUpdateTransaction is submitted without node admin signature + // (only has the new operator's signature, which is not sufficient) + var nodeUpdateTransaction = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(new AccountId(0, 0, 3)) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3))); + + // Then: The transaction should fail with INVALID_SIGNATURE + assertThatThrownBy(() -> { + var response = nodeUpdateTransaction.execute(client); + response.getReceipt(client); + }) + .isInstanceOf(ReceiptStatusException.class) + .hasMessageContaining("INVALID_SIGNATURE") + .satisfies(exception -> { + var receiptException = (ReceiptStatusException) exception; + assertThat(receiptException.receipt.status).isEqualTo(Status.INVALID_SIGNATURE); + }); + } + } + + @Test + @DisplayName( + "Given a node with an existing account ID, when a NodeUpdateTransaction is submitted to change the account ID to a new valid account with only the node admin key signature (missing account ID signature), then the transaction fails with INVALID_SIGNATURE") + void testNodeUpdateTransactionFailsWithInvalidSignatureWhenMissingAccountIdSignature() throws Exception { + // Set up the local network with 2 nodes + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .setMirrorNetwork(List.of("localhost:5600")) + .setTransportSecurity(false)) { + // Set the operator to be account 0.0.2 (has node admin privileges) + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: Create a new account that will be used as the new node account ID + var newAccountKey = PrivateKey.generateED25519(); + var newAccountResponse = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setInitialBalance(Hbar.from(2)) + .execute(client); + + var newAccountId = newAccountResponse.getReceipt(client).accountId; + + // When: A NodeUpdateTransaction is submitted with node admin signature + // but WITHOUT the new account ID's signature + var nodeUpdateTransaction = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(newAccountId) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3))); + + // Note: The operator (0.0.2) has node admin privileges, so the transaction + // is automatically signed with the operator's key (node admin signature). + // However, we're NOT signing with newAccountKey, which is required. + + // Then: The transaction should fail with INVALID_SIGNATURE + assertThatThrownBy(() -> { + var response = nodeUpdateTransaction.execute(client); + response.getReceipt(client); + }) + .isInstanceOf(ReceiptStatusException.class) + .hasMessageContaining("INVALID_SIGNATURE") + .satisfies(exception -> { + var receiptException = (ReceiptStatusException) exception; + assertThat(receiptException.receipt.status).isEqualTo(Status.INVALID_SIGNATURE); + }); + } + } + + // TODO - currently the test fails because returned status is Status.INVALID_SIGNATURE + @Test + @Disabled + @DisplayName( + "Given a node with an existing account ID, when a NodeUpdateTransaction is submitted to change the account ID to a non-existent account with proper signatures, then the transaction fails with INVALID_ACCOUNT_ID") + void testNodeUpdateTransactionFailsWithInvalidAccountIdForNonExistentAccount() throws Exception { + // Set up the local network with 2 nodes + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .setMirrorNetwork(List.of("localhost:5600")) + .setTransportSecurity(false)) { + // Set the operator to be account 0.0.2 (has admin privileges) + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: A node with an existing account ID (0.0.3) + // When: A NodeUpdateTransaction is submitted to change to a non-existent account (0.0.9999999) + var nodeUpdateTransaction = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(new AccountId(0, 0, 9999999)) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3))); + + // Then: The transaction should fail with INVALID_NODE_ACCOUNT_ID + assertThatThrownBy(() -> { + var response = nodeUpdateTransaction.execute(client); + response.getReceipt(client); + }) + .isInstanceOf(ReceiptStatusException.class) + .satisfies(exception -> { + var receiptException = (ReceiptStatusException) exception; + // The status could be INVALID_ACCOUNT_ID or INVALID_NODE_ACCOUNT_ID + assertThat(receiptException.receipt.status) + .isIn(Status.INVALID_ACCOUNT_ID, Status.INVALID_NODE_ACCOUNT_ID); + }); + } + } + + @Test + @DisplayName( + "Given a node with an existing account ID, when a NodeUpdateTransaction is submitted to change the account ID to a deleted account with proper signatures, then the transaction fails with ACCOUNT_DELETED") + void testNodeUpdateTransactionFailsWithAccountDeletedForDeletedAccount() throws Exception { + // Set up the local network with 2 nodes + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .setMirrorNetwork(List.of("localhost:5600")) + .setTransportSecurity(false)) { + // Set the operator to be account 0.0.2 (has admin privileges) + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: Create a new account that will be deleted + var newAccountKey = PrivateKey.generateED25519(); + var newAccountResponse = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setInitialBalance(Hbar.from(2)) + .execute(client); + + var newAccountId = newAccountResponse.getReceipt(client).accountId; + + // Delete the account (transfer balance to operator account) + var deleteResponse = new AccountDeleteTransaction() + .setAccountId(newAccountId) + .setTransferAccountId(client.getOperatorAccountId()) + .freezeWith(client) + .sign(newAccountKey) + .execute(client); + + var deleteReceipt = deleteResponse.getReceipt(client); + assertThat(deleteReceipt.status).isEqualTo(Status.SUCCESS); + + // When: A NodeUpdateTransaction is submitted to change to the deleted account + var nodeUpdateTransaction = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(newAccountId) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3))) + .freezeWith(client) + .sign(newAccountKey); + + // Then: The transaction should fail with ACCOUNT_DELETED + assertThatThrownBy(() -> { + var response = nodeUpdateTransaction.execute(client); + response.getReceipt(client); + }) + .isInstanceOf(ReceiptStatusException.class) + .hasMessageContaining("ACCOUNT_DELETED") + .satisfies(exception -> { + var receiptException = (ReceiptStatusException) exception; + assertThat(receiptException.receipt.status).isEqualTo(Status.ACCOUNT_DELETED); + }); + } + } + + @Test + @DisplayName( + "Given an successfully handled transaction with outdated node account id , when subsequent transaction that target the new node account id of that node is executed, then the transaction succeeds") + void testSubsequentTransactionWithNewNodeAccountIdSucceeds() throws Exception { + // Set the network + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = + Client.forNetwork(network).setTransportSecurity(false).setMirrorNetwork(List.of("localhost:5600"))) { + + // Set the operator to be account 0.0.2 + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: Create a new account that will be the node account id + var resp = new AccountCreateTransaction() + .setKey(originalOperatorKey.getPublicKey()) + .setInitialBalance(Hbar.from(1)) + .execute(client); + + var receipt = resp.setValidateStatus(true).getReceipt(client); + var newNodeAccountID = receipt.accountId; + assertThat(newNodeAccountID).isNotNull(); + + // Update node 0's account id (0.0.3 -> newNodeAccountID) + resp = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(newNodeAccountID) + .execute(client); + + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Wait for mirror node to import data + Thread.sleep(10000); + + // Given: Successfully handled transaction with outdated node account ID + // This transaction targets old node account ID (0.0.3) and new node account ID (0.0.4) + // Node 0.0.3 will fail with INVALID_NODE_ACCOUNT and SDK will retry with 0.0.4 + var newAccountKey = PrivateKey.generateED25519(); + resp = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3), new AccountId(0, 0, 4))) + .execute(client); + + // Verify the SDK handled the error and completed the transaction + assertThat(resp).isNotNull(); + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // When: Subsequent transaction targets the NEW node account ID directly + var anotherAccountKey = PrivateKey.generateED25519(); + resp = new AccountCreateTransaction() + .setKey(anotherAccountKey.getPublicKey()) + .setNodeAccountIds(List.of(newNodeAccountID)) + .execute(client); + + // Then: The transaction should succeed without any errors + assertThat(resp).isNotNull(); + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Cleanup: Revert the node account id (newNodeAccountID -> 0.0.3) + resp = new NodeUpdateTransaction() + .setNodeId(0) + .setNodeAccountIds(List.of(newNodeAccountID)) + .setDescription("testUpdated") + .setAccountId(new AccountId(0, 0, 3)) + .execute(client); + + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + } + } + + @Test + @DisplayName( + "Given an SDK receives INVALID_NODE_ACCOUNT for a node, when updating its network configuration, then the SDK updates its network with the latest node account IDs for subsequent transactions") + void testSdkUpdatesNetworkConfigurationOnInvalidNodeAccount() throws Exception { + // Set the network + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:51211", new AccountId(0, 0, 4)); + + try (var client = + Client.forNetwork(network).setTransportSecurity(false).setMirrorNetwork(List.of("localhost:5600"))) { + + // Set the operator to be account 0.0.2 + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + // Given: Verify initial network configuration + var initialNetwork = client.getNetwork(); + assertThat(initialNetwork).containsEntry("localhost:50211", new AccountId(0, 0, 3)); + assertThat(initialNetwork).containsEntry("localhost:51211", new AccountId(0, 0, 4)); + + // Create a new account that will be the node account id + var resp = new AccountCreateTransaction() + .setKey(originalOperatorKey.getPublicKey()) + .setInitialBalance(Hbar.from(1)) + .execute(client); + + var receipt = resp.setValidateStatus(true).getReceipt(client); + var newNodeAccountID = receipt.accountId; + assertThat(newNodeAccountID).isNotNull(); + + // Update node 0's account id (0.0.3 -> newNodeAccountID) + resp = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(newNodeAccountID) + .execute(client); + + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Wait for mirror node to import data + Thread.sleep(10000); + + // When: Submit transaction with outdated node account ID (0.0.3) + // This will trigger INVALID_NODE_ACCOUNT error and the SDK should update its network + var newAccountKey = PrivateKey.generateED25519(); + resp = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setNodeAccountIds(List.of(new AccountId(0, 0, 3), new AccountId(0, 0, 4))) + .execute(client); + + // Verify the transaction succeeded (SDK retried with another node) + assertThat(resp).isNotNull(); + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Then: Verify the SDK has updated its network configuration + // The network should now contain the new node account ID + var updatedNetwork = client.getNetwork(); + + // The network map should have been updated with the new account ID + // Note: The address book update happens asynchronously, so we verify + // that subsequent transactions can successfully target the new account ID + + // Verify subsequent transaction with the new node account ID succeeds + var anotherAccountKey = PrivateKey.generateED25519(); + resp = new AccountCreateTransaction() + .setKey(anotherAccountKey.getPublicKey()) + .setNodeAccountIds(List.of(newNodeAccountID)) + .execute(client); + + assertThat(resp).isNotNull(); + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Verify the network configuration now includes the new account ID + // This confirms the SDK successfully updated its network from the address book + var finalNetwork = client.getNetwork(); + var hasNewAccountId = + finalNetwork.values().stream().anyMatch(accountId -> accountId.equals(newNodeAccountID)); + assertThat(hasNewAccountId) + .as("Client network should contain the new node account ID after address book update") + .isTrue(); + + // Cleanup: Revert the node account id (newNodeAccountID -> 0.0.3) + resp = new NodeUpdateTransaction() + .setNodeId(0) + .setNodeAccountIds(List.of(newNodeAccountID)) + .setDescription("testUpdated") + .setAccountId(new AccountId(0, 0, 3)) + .execute(client); + + receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + } + } +}