diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d732a6bf2..eb27b6f59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -199,13 +199,15 @@ jobs: -PHEDERA_NETWORK=$HEDERA_NETWORK \ test \ testIntegration \ - -PskipNodeUpdateTest=true + -PskipNodeUpdateTest=true \ + --rerun-tasks ./gradlew \ -POPERATOR_ID=$OPERATOR_ID \ -POPERATOR_KEY=$OPERATOR_KEY \ -PHEDERA_NETWORK=$HEDERA_NETWORK \ - :aggregation:testCodeCoverageReport + :aggregation:testCodeCoverageReport \ + --rerun-tasks - name: Upload coverage to Codecov if: ${{ github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }} @@ -249,6 +251,7 @@ jobs: with: installMirrorNode: true dualMode: true + hieroVersion: "v0.68.0" - name: Build SDK run: ./gradlew assemble @@ -265,6 +268,7 @@ jobs: -PHEDERA_NETWORK=$HEDERA_NETWORK \ testIntegration \ --tests "*NodeUpdateTransactionIntegrationTest" + --rerun-tasks run-examples: name: Run Examples 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..6695d8594 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,38 @@ 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); + + // Execute synchronously - no async complexity + var addressBook = new AddressBookQuery().setFileId(fileId).execute(this); // ← Synchronous! + + logger.debug("Received address book with {} nodes", addressBook.nodeAddresses.size()); + + // Update the network + this.setNetworkFromAddressBook(addressBook); + + logger.info("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..db9b12e75 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(); @@ -679,10 +695,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 +742,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 +766,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 +784,25 @@ 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(); + if (status == com.hedera.hashgraph.sdk.Status.INVALID_NODE_ACCOUNT) { + if (logger.isEnabledForLevel(LogLevel.TRACE)) { + logger.trace( + "Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}", + grpcRequest + .getNode() + .getAccountId(), + attempt); + } + // Schedule async address book update + client.updateNetworkFromAddressBook(); + // Mark this node as unhealthy + client.network.increaseBackoff(grpcRequest.getNode()); + } executeAsyncInternal( client, attempt + 1, @@ -779,6 +811,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 +902,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 +1010,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 +1042,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/NodeUpdateTransactionIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateTransactionIntegrationTest.java index 1b00d498d..64680a5a7 100644 --- a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateTransactionIntegrationTest.java +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateTransactionIntegrationTest.java @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 package com.hedera.hashgraph.sdk.test.integration; -import com.hedera.hashgraph.sdk.AccountId; -import com.hedera.hashgraph.sdk.Client; -import com.hedera.hashgraph.sdk.Endpoint; -import com.hedera.hashgraph.sdk.NodeUpdateTransaction; -import com.hedera.hashgraph.sdk.PrivateKey; +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hedera.hashgraph.sdk.*; 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; @@ -64,4 +65,472 @@ void canDeleteGrpcWebProxyEndpoint() throws Exception { response.getReceipt(client); } } + + // ================== hip-1299 changing node account ID (dab) tests ================== + @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 newAccountId = createAccount(client, newAccountKey.getPublicKey(), Hbar.from(10)); + + // 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 newNodeAccountID = createAccount(client, originalOperatorKey.getPublicKey(), Hbar.from(1)); + assertThat(newNodeAccountID).isNotNull(); + + // Update node account id (0.0.3 -> newNodeAccountID) + var 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()); + var receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + + // Wait for mirror node to import data + Thread.sleep(10000); + + // 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 + executeAccountCreate(client, List.of(new AccountId(0, 0, 3), new AccountId(0, 0, 4))); + + // This transaction should succeed using the updated node account ID + executeAccountCreate(client, List.of(newNodeAccountID)); + + // 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 newOperatorAccountId = createAccount(client, newOperatorKey.getPublicKey(), Hbar.from(2)); + + // 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 newAccountId = createAccount(client, newAccountKey.getPublicKey(), Hbar.from(2)); + + // 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 newAccountId = createAccount(client, newAccountKey.getPublicKey(), Hbar.from(2)); + + // 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 newNodeAccountID = createAccount(client, originalOperatorKey.getPublicKey(), Hbar.from(1)); + assertThat(newNodeAccountID).isNotNull(); + + // Update node 0's account id (0.0.3 -> newNodeAccountID) + var resp = new NodeUpdateTransaction() + .setNodeId(0) + .setDescription("testUpdated") + .setAccountId(newNodeAccountID) + .execute(client); + + var 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 + executeAccountCreate(client, List.of(new AccountId(0, 0, 3), new AccountId(0, 0, 4))); + + // When: Subsequent transaction targets the NEW node account ID directly + executeAccountCreate(client, List.of(newNodeAccountID)); + + // 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 { + 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"))) { + + var originalOperatorKey = PrivateKey.fromString( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"); + client.setOperator(new AccountId(0, 0, 2), originalOperatorKey); + + var newNodeAccountID = createAccount(client, originalOperatorKey.getPublicKey(), Hbar.from(1)); + assertThat(newNodeAccountID).isNotNull(); + + updateNodeAccountId(client, 0, newNodeAccountID, null); + + Thread.sleep(10000); + + // Trigger INVALID_NODE_ACCOUNT error and retry + executeAccountCreate(client, List.of(new AccountId(0, 0, 3), new AccountId(0, 0, 4))); + + // Verify subsequent transaction with new node account ID + executeAccountCreate(client, List.of(newNodeAccountID)); + + // Verify the network configuration now includes the new account ID + 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 + updateNodeAccountId(client, 0, new AccountId(0, 0, 3), List.of(newNodeAccountID)); + } + } + + private AccountId createAccount(Client client, Key key, Hbar initialBalance) throws Exception { + var resp = new AccountCreateTransaction() + .setKey(key) + .setInitialBalance(initialBalance) + .execute(client); + return resp.setValidateStatus(true).getReceipt(client).accountId; + } + + private void updateNodeAccountId(Client client, long nodeId, AccountId newAccountId, List nodeAccountIds) + throws Exception { + var transaction = new NodeUpdateTransaction() + .setNodeId(nodeId) + .setDescription("testUpdated") + .setAccountId(newAccountId); + + if (nodeAccountIds != null) { + transaction.setNodeAccountIds(nodeAccountIds); + } + + var resp = transaction.execute(client); + var receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + } + + private void executeAccountCreate(Client client, List nodeAccountIds) throws Exception { + var newAccountKey = PrivateKey.generateED25519(); + var resp = new AccountCreateTransaction() + .setKey(newAccountKey.getPublicKey()) + .setNodeAccountIds(nodeAccountIds) + .execute(client); + + assertThat(resp).isNotNull(); + var receipt = resp.setValidateStatus(true).getReceipt(client); + assertThat(receipt.status).isEqualTo(Status.SUCCESS); + } }