From f8cf1a6ebdeee688f22fb1524849cceb60a9a47e Mon Sep 17 00:00:00 2001 From: emiliyank Date: Tue, 21 Oct 2025 17:25:57 +0300 Subject: [PATCH 01/10] initial logic for changing node account id Signed-off-by: emiliyank --- .../java/com/hedera/hashgraph/sdk/Client.java | 12 +++ .../com/hedera/hashgraph/sdk/Executable.java | 79 ++++++++++++++++--- .../hedera/hashgraph/sdk/ExecutableTest.java | 63 +++++++++++++++ 3 files changed, 142 insertions(+), 12 deletions(-) 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 b26a6e95fc..4bd9ee868e 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,18 @@ 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() { + scheduleNetworkUpdate(Duration.ZERO); + 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 0d0f138a7b..53b46f311e 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java @@ -390,7 +390,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech throw new TimeoutException(); } - GrpcRequest grpcRequest = new GrpcRequest(client.network, attempt, currentTimeout); + GrpcRequest grpcRequest = new GrpcRequest(client.network, client, attempt, currentTimeout); Node node = grpcRequest.getNode(); ResponseT response = null; @@ -712,7 +712,7 @@ private void executeAsyncInternal( var timeoutTime = Instant.now().plus(timeout); GrpcRequest grpcRequest = - new GrpcRequest(client.network, attempt, Duration.between(Instant.now(), timeoutTime)); + new GrpcRequest(client.network, client, attempt, Duration.between(Instant.now(), timeoutTime)); Supplier> afterUnhealthyDelay = () -> { return grpcRequest.getNode().isHealthy() @@ -868,6 +868,7 @@ ExecutionState getExecutionState(Status status, ResponseT response) { case PLATFORM_NOT_ACTIVE: return ExecutionState.SERVER_ERROR; case BUSY: + case INVALID_NODE_ACCOUNT_ID: return ExecutionState.RETRY; case OK: return ExecutionState.SUCCESS; @@ -881,6 +882,9 @@ class GrpcRequest { @Nullable private final Network network; + @Nullable + private final Client client; + private final Node node; private final int attempt; // private final ClientCall call; @@ -894,6 +898,22 @@ class GrpcRequest { GrpcRequest(@Nullable Network network, int attempt, Duration grpcDeadline) { this.network = network; + this.client = null; + this.attempt = attempt; + this.grpcDeadline = grpcDeadline; + this.node = getNodeForExecute(attempt); + this.request = getRequestForExecute(); // node index gets incremented here + this.startAt = System.nanoTime(); + + // Exponential back-off for Delayer: 250ms, 500ms, 1s, 2s, 4s, 8s, ... 8s + delay = (long) Math.min( + Objects.requireNonNull(minBackoff).toMillis() * Math.pow(2, attempt - 1.0), + Objects.requireNonNull(maxBackoff).toMillis()); + } + + GrpcRequest(@Nullable Network network, @Nullable Client client, int attempt, Duration grpcDeadline) { + this.network = network; + this.client = client; this.attempt = attempt; this.grpcDeadline = grpcDeadline; this.node = getNodeForExecute(attempt); @@ -974,7 +994,19 @@ O mapResponse() { } void handleResponse(ResponseT response, Status status, ExecutionState executionState) { - node.decreaseBackoff(); + // Special handling for INVALID_NODE_ACCOUNT_ID - mark node as unusable and update address book + if (status == Status.INVALID_NODE_ACCOUNT_ID) { + network.increaseBackoff(node); + logger.warn( + "Node {} marked as unusable due to INVALID_NODE_ACCOUNT_ID during attempt #{}", + node.getAccountId(), + attempt); + + // Trigger immediate address book update to get latest node account IDs + triggerImmediateAddressBookUpdate(); + } else { + node.decreaseBackoff(); + } this.response = Executable.this.responseListener.apply(response); this.responseStatus = status; @@ -993,19 +1025,27 @@ void handleResponse(ResponseT response, Status status, ExecutionState executionS } switch (executionState) { case RETRY -> { + if (status == Status.INVALID_NODE_ACCOUNT_ID) { + logger.warn( + "Retrying with different node after INVALID_NODE_ACCOUNT_ID from node {} during attempt #{}", + node.getAccountId(), + attempt); + } else { + logger.warn( + "Retrying in {} ms after failure with node {} during attempt #{}: {}", + delay, + node.getAccountId(), + attempt, + responseStatus); + } + verboseLog(node); + } + case SERVER_ERROR -> logger.warn( - "Retrying in {} ms after failure with node {} during attempt #{}: {}", - delay, + "Problem submitting request to node {} for attempt #{}, retry with new node: {}", node.getAccountId(), attempt, responseStatus); - verboseLog(node); - } - case SERVER_ERROR -> logger.warn( - "Problem submitting request to node {} for attempt #{}, retry with new node: {}", - node.getAccountId(), - attempt, - responseStatus); default -> {} } } @@ -1025,5 +1065,20 @@ void verboseLog(Node node) { System.currentTimeMillis(), this.getClass().getSimpleName()); } + + /** + * Trigger an immediate address book update when INVALID_NODE_ACCOUNT_ID occurs. + * This ensures the client gets the latest node account IDs for subsequent transactions. + */ + void triggerImmediateAddressBookUpdate() { + if (client != null) { + try { + client.updateNetworkFromAddressBook(); + logger.info("Triggered immediate address book update after INVALID_NODE_ACCOUNT_ID"); + } catch (Exception e) { + logger.warn("Failed to trigger address book update after INVALID_NODE_ACCOUNT_ID", e); + } + } + } } } 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 9e816b2d6e..db14724ce4 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,7 @@ 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); + assertThat(tx.getExecutionState(Status.INVALID_NODE_ACCOUNT_ID, null)).isEqualTo(ExecutionState.RETRY); assertThat(tx.getExecutionState(Status.OK, null)).isEqualTo(ExecutionState.SUCCESS); assertThat(tx.getExecutionState(Status.ACCOUNT_DELETED, null)).isEqualTo(ExecutionState.REQUEST_ERROR); } @@ -526,6 +528,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_ID; + } + }; + 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_ID) + .build(); + + tx.blockingUnaryCall = (grpcRequest) -> txResp; + + // This should retry with a different node due to INVALID_NODE_ACCOUNT_ID + assertThatExceptionOfType(MaxAttemptsExceededException.class).isThrownBy(() -> tx.execute(client)); + + // Verify that increaseBackoff was called on the network for each node that returned INVALID_NODE_ACCOUNT_ID + 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_ID; + } + }; + 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_ID) + .build(); + + tx.blockingUnaryCall = (grpcRequest) -> txResp; + + // This should trigger address book update due to INVALID_NODE_ACCOUNT_ID + 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, From 0c6c579ccfae13a4612cb95884b436e84aa2730b Mon Sep 17 00:00:00 2001 From: emiliyank Date: Tue, 21 Oct 2025 17:34:02 +0300 Subject: [PATCH 02/10] remove client property from GrpcRequest Signed-off-by: emiliyank --- .../com/hedera/hashgraph/sdk/Executable.java | 53 +++++-------------- 1 file changed, 13 insertions(+), 40 deletions(-) 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 53b46f311e..f2634799db 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java @@ -390,7 +390,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech throw new TimeoutException(); } - GrpcRequest grpcRequest = new GrpcRequest(client.network, client, attempt, currentTimeout); + GrpcRequest grpcRequest = new GrpcRequest(client.network, attempt, currentTimeout); Node node = grpcRequest.getNode(); ResponseT response = null; @@ -433,7 +433,7 @@ 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: @@ -712,7 +712,7 @@ private void executeAsyncInternal( var timeoutTime = Instant.now().plus(timeout); GrpcRequest grpcRequest = - new GrpcRequest(client.network, client, attempt, Duration.between(Instant.now(), timeoutTime)); + new GrpcRequest(client.network, attempt, Duration.between(Instant.now(), timeoutTime)); Supplier> afterUnhealthyDelay = () -> { return grpcRequest.getNode().isHealthy() @@ -767,7 +767,7 @@ 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: @@ -882,9 +882,6 @@ class GrpcRequest { @Nullable private final Network network; - @Nullable - private final Client client; - private final Node node; private final int attempt; // private final ClientCall call; @@ -898,22 +895,6 @@ class GrpcRequest { GrpcRequest(@Nullable Network network, int attempt, Duration grpcDeadline) { this.network = network; - this.client = null; - this.attempt = attempt; - this.grpcDeadline = grpcDeadline; - this.node = getNodeForExecute(attempt); - this.request = getRequestForExecute(); // node index gets incremented here - this.startAt = System.nanoTime(); - - // Exponential back-off for Delayer: 250ms, 500ms, 1s, 2s, 4s, 8s, ... 8s - delay = (long) Math.min( - Objects.requireNonNull(minBackoff).toMillis() * Math.pow(2, attempt - 1.0), - Objects.requireNonNull(maxBackoff).toMillis()); - } - - GrpcRequest(@Nullable Network network, @Nullable Client client, int attempt, Duration grpcDeadline) { - this.network = network; - this.client = client; this.attempt = attempt; this.grpcDeadline = grpcDeadline; this.node = getNodeForExecute(attempt); @@ -993,7 +974,7 @@ O mapResponse() { return Executable.this.mapResponse(response, node.getAccountId(), request); } - void handleResponse(ResponseT response, Status status, ExecutionState executionState) { + void handleResponse(ResponseT response, Status status, ExecutionState executionState, @Nullable Client client) { // Special handling for INVALID_NODE_ACCOUNT_ID - mark node as unusable and update address book if (status == Status.INVALID_NODE_ACCOUNT_ID) { network.increaseBackoff(node); @@ -1003,7 +984,14 @@ void handleResponse(ResponseT response, Status status, ExecutionState executionS attempt); // Trigger immediate address book update to get latest node account IDs - triggerImmediateAddressBookUpdate(); + if (client != null) { + try { + client.updateNetworkFromAddressBook(); + logger.info("Triggered immediate address book update after INVALID_NODE_ACCOUNT_ID"); + } catch (Exception e) { + logger.warn("Failed to trigger address book update after INVALID_NODE_ACCOUNT_ID", e); + } + } } else { node.decreaseBackoff(); } @@ -1065,20 +1053,5 @@ void verboseLog(Node node) { System.currentTimeMillis(), this.getClass().getSimpleName()); } - - /** - * Trigger an immediate address book update when INVALID_NODE_ACCOUNT_ID occurs. - * This ensures the client gets the latest node account IDs for subsequent transactions. - */ - void triggerImmediateAddressBookUpdate() { - if (client != null) { - try { - client.updateNetworkFromAddressBook(); - logger.info("Triggered immediate address book update after INVALID_NODE_ACCOUNT_ID"); - } catch (Exception e) { - logger.warn("Failed to trigger address book update after INVALID_NODE_ACCOUNT_ID", e); - } - } - } } } From b04c0c29d2258023ed2331b41f9b08a01abf807c Mon Sep 17 00:00:00 2001 From: emiliyank Date: Sun, 26 Oct 2025 22:09:47 +0200 Subject: [PATCH 03/10] add NodeUpdateAccountIdIntegrationTest Signed-off-by: emiliyank --- .../NodeUpdateAccountIdIntegrationTest.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateAccountIdIntegrationTest.java 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 0000000000..86c1089fac --- /dev/null +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/NodeUpdateAccountIdIntegrationTest.java @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.hedera.hashgraph.sdk.test.integration; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.AccountCreateTransaction; +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.Status; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * 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 + @DisplayName("NodeUpdateTransaction should succeed when updating account ID with proper signatures") + 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:50212", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .setMirrorNetwork(List.of("localhost:5600")) + .setTransportSecurity(false) + .setVerifyCertificates(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 + // 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(); + } + } +} From 017dd5e3f3122f52f687468fe015e69414a7f597 Mon Sep 17 00:00:00 2001 From: emiliyank Date: Mon, 3 Nov 2025 22:56:32 +0200 Subject: [PATCH 04/10] hip 1299 - in Executable handle status INVALID_NODE_ACCOUNT_ID Signed-off-by: emiliyank --- .../com/hedera/hashgraph/sdk/Executable.java | 95 +++++++++++-------- .../hedera/hashgraph/sdk/ExecutableTest.java | 4 +- 2 files changed, 60 insertions(+), 39 deletions(-) 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 f2634799db..714902738e 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); @@ -438,6 +440,19 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech switch (executionState) { case SERVER_ERROR: lastException = grpcRequest.mapStatusException(); + advanceRequest(); // Advance to next node before retrying + + // Handle INVALID_NODE_ACCOUNT_ID after advancing (matches Go SDK's executionStateRetryWithAnotherNode) + if (status == Status.INVALID_NODE_ACCOUNT_ID) { + logger.trace( + "Received INVALID_NODE_ACCOUNT_ID; updating address book and marking node {} as unhealthy, attempt #{}", + node.getAccountId(), + attempt); + // Schedule async address book update (matches Go's defer client._UpdateAddressBook()) + 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, to match Go SDK behavior + // 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, @@ -771,6 +788,20 @@ private void executeAsyncInternal( switch (executionState) { case SERVER_ERROR: + advanceRequest(); // Advance to next node before retrying + + // Handle INVALID_NODE_ACCOUNT_ID after advancing (matches Go SDK's executionStateRetryWithAnotherNode) + if (status == Status.INVALID_NODE_ACCOUNT_ID) { + logger.trace( + "Received INVALID_NODE_ACCOUNT_ID; 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 +810,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) @@ -868,8 +900,11 @@ ExecutionState getExecutionState(Status status, ResponseT response) { case PLATFORM_NOT_ACTIVE: return ExecutionState.SERVER_ERROR; case BUSY: - case INVALID_NODE_ACCOUNT_ID: return ExecutionState.RETRY; + case INVALID_NODE_ACCOUNT_ID: + // Matches Go SDK's executionStateRetryWithAnotherNode behavior: + // immediately retry with next node without delay + return ExecutionState.SERVER_ERROR; case OK: return ExecutionState.SUCCESS; default: @@ -975,24 +1010,9 @@ O mapResponse() { } void handleResponse(ResponseT response, Status status, ExecutionState executionState, @Nullable Client client) { - // Special handling for INVALID_NODE_ACCOUNT_ID - mark node as unusable and update address book - if (status == Status.INVALID_NODE_ACCOUNT_ID) { - network.increaseBackoff(node); - logger.warn( - "Node {} marked as unusable due to INVALID_NODE_ACCOUNT_ID during attempt #{}", - node.getAccountId(), - attempt); - - // Trigger immediate address book update to get latest node account IDs - if (client != null) { - try { - client.updateNetworkFromAddressBook(); - logger.info("Triggered immediate address book update after INVALID_NODE_ACCOUNT_ID"); - } catch (Exception e) { - logger.warn("Failed to trigger address book update after INVALID_NODE_ACCOUNT_ID", e); - } - } - } else { + // Note: For INVALID_NODE_ACCOUNT_ID, 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_ID) { node.decreaseBackoff(); } @@ -1013,27 +1033,26 @@ void handleResponse(ResponseT response, Status status, ExecutionState executionS } switch (executionState) { case RETRY -> { - if (status == Status.INVALID_NODE_ACCOUNT_ID) { - logger.warn( - "Retrying with different node after INVALID_NODE_ACCOUNT_ID from node {} during attempt #{}", - node.getAccountId(), - attempt); - } else { + logger.warn( + "Retrying in {} ms after failure with node {} during attempt #{}: {}", + delay, + node.getAccountId(), + attempt, + responseStatus); + verboseLog(node); + } + case SERVER_ERROR -> { + // Note: INVALID_NODE_ACCOUNT_ID is handled after advanceRequest() in execute methods + // to match Go SDK's executionStateRetryWithAnotherNode behavior + if (status != Status.INVALID_NODE_ACCOUNT_ID) { logger.warn( - "Retrying in {} ms after failure with node {} during attempt #{}: {}", - delay, + "Problem submitting request to node {} for attempt #{}, retry with new node: {}", node.getAccountId(), attempt, responseStatus); + verboseLog(node); } - verboseLog(node); } - case SERVER_ERROR -> - logger.warn( - "Problem submitting request to node {} for attempt #{}, retry with new node: {}", - node.getAccountId(), - attempt, - responseStatus); 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 db14724ce4..d14a8b42a7 100644 --- a/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java @@ -512,7 +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); - assertThat(tx.getExecutionState(Status.INVALID_NODE_ACCOUNT_ID, null)).isEqualTo(ExecutionState.RETRY); + // INVALID_NODE_ACCOUNT_ID 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_ID, 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); } From c25d9cd6adebb87d370f87a1040903675f9e367e Mon Sep 17 00:00:00 2001 From: emiliyank Date: Mon, 3 Nov 2025 23:23:55 +0200 Subject: [PATCH 05/10] hip 1299 - add integration test to update AddressBook Signed-off-by: emiliyank --- .../NodeUpdateAccountIdIntegrationTest.java | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) 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 index 86c1089fac..e0c76a07fc 100644 --- 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 @@ -10,6 +10,8 @@ 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; @@ -26,6 +28,7 @@ class NodeUpdateAccountIdIntegrationTest { @Test + @Disabled @DisplayName("NodeUpdateTransaction should succeed when updating account ID with proper signatures") void shouldSucceedWhenUpdatingNodeAccountIdWithProperSignatures() throws Exception { // Set up the local network with 2 nodes @@ -81,4 +84,84 @@ void shouldSucceedWhenUpdatingNodeAccountIdWithProperSignatures() throws Excepti }).doesNotThrowAnyException(); } } + + @Test + @DisplayName("NodeUpdateTransaction can change node account ID, update address book, and retry automatically") + void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() throws Exception { + // Set the network + var network = new HashMap(); + network.put("localhost:50211", new AccountId(0, 0, 3)); + network.put("localhost:50212", new AccountId(0, 0, 4)); + + try (var client = Client.forNetwork(network) + .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); + + 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_ID (because it now uses newNodeAccountID) + // The SDK should automatically: + // 1. Detect INVALID_NODE_ACCOUNT_ID 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); + } + } } From 9bcfbd35b92964a33938a4271f0b7503c8ed61de Mon Sep 17 00:00:00 2001 From: emiliyank Date: Tue, 4 Nov 2025 10:57:08 +0200 Subject: [PATCH 06/10] hip 1299 - switch error from INVALID_NODE_ACCOUNT_ID to INVALID_NODE_ACCOUNT Signed-off-by: emiliyank --- .../com/hedera/hashgraph/sdk/Executable.java | 33 ++++++++-------- .../hedera/hashgraph/sdk/ExecutableTest.java | 18 ++++----- .../NodeUpdateAccountIdIntegrationTest.java | 38 +++++++++---------- 3 files changed, 45 insertions(+), 44 deletions(-) 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 714902738e..bbd16fb89b 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java @@ -441,11 +441,11 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech case SERVER_ERROR: lastException = grpcRequest.mapStatusException(); advanceRequest(); // Advance to next node before retrying - - // Handle INVALID_NODE_ACCOUNT_ID after advancing (matches Go SDK's executionStateRetryWithAnotherNode) - if (status == Status.INVALID_NODE_ACCOUNT_ID) { + + // Handle INVALID_NODE_ACCOUNT after advancing (matches Go SDK's executionStateRetryWithAnotherNode) + if (status == Status.INVALID_NODE_ACCOUNT) { logger.trace( - "Received INVALID_NODE_ACCOUNT_ID; updating address book and marking node {} as unhealthy, attempt #{}", + "Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}", node.getAccountId(), attempt); // Schedule async address book update (matches Go's defer client._UpdateAddressBook()) @@ -789,19 +789,21 @@ private void executeAsyncInternal( switch (executionState) { case SERVER_ERROR: advanceRequest(); // Advance to next node before retrying - - // Handle INVALID_NODE_ACCOUNT_ID after advancing (matches Go SDK's executionStateRetryWithAnotherNode) - if (status == Status.INVALID_NODE_ACCOUNT_ID) { + + // Handle INVALID_NODE_ACCOUNT after advancing (matches Go SDK's + // executionStateRetryWithAnotherNode) + if (status == Status.INVALID_NODE_ACCOUNT) { logger.trace( - "Received INVALID_NODE_ACCOUNT_ID; updating address book and marking node {} as unhealthy, attempt #{}", + "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()) + // 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, @@ -901,9 +903,10 @@ ExecutionState getExecutionState(Status status, ResponseT response) { return ExecutionState.SERVER_ERROR; case BUSY: return ExecutionState.RETRY; - case INVALID_NODE_ACCOUNT_ID: + case INVALID_NODE_ACCOUNT: // Matches Go SDK's executionStateRetryWithAnotherNode behavior: // immediately retry with next node without delay + // This occurs when a node's account ID has changed return ExecutionState.SERVER_ERROR; case OK: return ExecutionState.SUCCESS; @@ -1010,9 +1013,9 @@ O mapResponse() { } void handleResponse(ResponseT response, Status status, ExecutionState executionState, @Nullable Client client) { - // Note: For INVALID_NODE_ACCOUNT_ID, we don't mark the node as unhealthy here + // 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_ID) { + if (status != Status.INVALID_NODE_ACCOUNT) { node.decreaseBackoff(); } @@ -1042,9 +1045,9 @@ void handleResponse(ResponseT response, Status status, ExecutionState executionS verboseLog(node); } case SERVER_ERROR -> { - // Note: INVALID_NODE_ACCOUNT_ID is handled after advanceRequest() in execute methods + // Note: INVALID_NODE_ACCOUNT is handled after advanceRequest() in execute methods // to match Go SDK's executionStateRetryWithAnotherNode behavior - if (status != Status.INVALID_NODE_ACCOUNT_ID) { + if (status != Status.INVALID_NODE_ACCOUNT) { logger.warn( "Problem submitting request to node {} for attempt #{}, retry with new node: {}", node.getAccountId(), 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 d14a8b42a7..0848f0f03e 100644 --- a/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java @@ -512,9 +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_ID now returns SERVER_ERROR to match Go SDK's executionStateRetryWithAnotherNode + // 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_ID, null)).isEqualTo(ExecutionState.SERVER_ERROR); + 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); } @@ -542,22 +542,22 @@ void shouldMarkNodeAsUnusableOnInvalidNodeAccountId() throws PrecheckStatusExcep var tx = new DummyTransaction() { @Override Status mapResponseStatus(com.hedera.hashgraph.sdk.proto.TransactionResponse response) { - return Status.INVALID_NODE_ACCOUNT_ID; + 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_ID) + .setNodeTransactionPrecheckCode(ResponseCodeEnum.INVALID_NODE_ACCOUNT) .build(); tx.blockingUnaryCall = (grpcRequest) -> txResp; - // This should retry with a different node due to INVALID_NODE_ACCOUNT_ID + // 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_ID + // Verify that increaseBackoff was called on the network for each node that returned INVALID_NODE_ACCOUNT verify(network, atLeastOnce()).increaseBackoff(any(Node.class)); } @@ -569,19 +569,19 @@ void shouldTriggerAddressBookUpdateOnInvalidNodeAccountId() throws PrecheckStatu var tx = new DummyTransaction() { @Override Status mapResponseStatus(com.hedera.hashgraph.sdk.proto.TransactionResponse response) { - return Status.INVALID_NODE_ACCOUNT_ID; + 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_ID) + .setNodeTransactionPrecheckCode(ResponseCodeEnum.INVALID_NODE_ACCOUNT) .build(); tx.blockingUnaryCall = (grpcRequest) -> txResp; - // This should trigger address book update due to INVALID_NODE_ACCOUNT_ID + // 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) 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 index e0c76a07fc..98fbb6ac57 100644 --- 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 @@ -1,8 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 package com.hedera.hashgraph.sdk.test.integration; -import com.hedera.hashgraph.sdk.AccountId; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + import com.hedera.hashgraph.sdk.AccountCreateTransaction; +import com.hedera.hashgraph.sdk.AccountId; import com.hedera.hashgraph.sdk.Client; import com.hedera.hashgraph.sdk.Hbar; import com.hedera.hashgraph.sdk.NodeUpdateTransaction; @@ -10,14 +13,10 @@ 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; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; - /** * Integration test for NodeUpdateTransaction functionality, specifically testing * the Dynamic Address Book (DAB) enhancement for updating node account IDs. @@ -69,19 +68,18 @@ void shouldSucceedWhenUpdatingNodeAccountIdWithProperSignatures() throws Excepti .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); + 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(); + 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(); } } @@ -91,10 +89,10 @@ void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() th // Set the network var network = new HashMap(); network.put("localhost:50211", new AccountId(0, 0, 3)); - network.put("localhost:50212", new AccountId(0, 0, 4)); + network.put("localhost:51211", new AccountId(0, 0, 4)); - try (var client = Client.forNetwork(network) - .setMirrorNetwork(List.of("localhost:5600"))) { + 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( @@ -127,9 +125,9 @@ void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() th var newAccountKey = PrivateKey.generateED25519(); // Submit to node 3 and node 4 - // Node 3 will fail with INVALID_NODE_ACCOUNT_ID (because it now uses newNodeAccountID) + // Node 3 will fail with INVALID_NODE_ACCOUNT (because it now uses newNodeAccountID) // The SDK should automatically: - // 1. Detect INVALID_NODE_ACCOUNT_ID error + // 1. Detect INVALID_NODE_ACCOUNT error // 2. Advance to next node // 3. Update the address book asynchronously // 4. Mark node 3 as unhealthy From c78f3594ba7c198b170a91fe76d7b173dd329319 Mon Sep 17 00:00:00 2001 From: emiliyank Date: Thu, 6 Nov 2025 09:06:40 +0200 Subject: [PATCH 07/10] hip 1299 - use new method Client.updateNetworkFromAddressBook() Signed-off-by: emiliyank --- .../java/com/hedera/hashgraph/sdk/Client.java | 58 +++++++++++++++++-- .../com/hedera/hashgraph/sdk/Executable.java | 34 +++++++---- .../NodeUpdateAccountIdIntegrationTest.java | 4 ++ 3 files changed, 82 insertions(+), 14 deletions(-) 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 4bd9ee868e..6bc73385a9 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java @@ -473,6 +473,40 @@ public synchronized Client setMirrorNetwork(List network) throws Interru return this; } + /** + * Trigger an immediate address book update. + * This will fetch the address book even if periodic updates are disabled. + */ + 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; + } + private synchronized void scheduleNetworkUpdate(@Nullable Duration delay) { if (delay == null) { networkUpdateFuture = null; @@ -1411,10 +1445,26 @@ public synchronized Client setNetworkUpdatePeriod(Duration networkUpdatePeriod) * * @return {@code this} */ - public synchronized Client updateNetworkFromAddressBook() { - scheduleNetworkUpdate(Duration.ZERO); - return this; - } +// public synchronized Client updateNetworkFromAddressBook() { +//// scheduleNetworkUpdate(Duration.ZERO); +// updateNetworkFromAddressBook2(); +// +// if (networkUpdateFuture != null) { +// try { +// networkUpdateFuture.get(30, TimeUnit.SECONDS); +// logger.debug("Address book update completed successfully"); +// } catch (TimeoutException e) { +// logger.warn("Address book update timed out after 30 seconds", e); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// logger.warn("Address book update was interrupted", e); +// } catch (ExecutionException e) { +// logger.warn("Address book update failed", 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 bbd16fb89b..b8f385919d 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java @@ -442,13 +442,13 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech lastException = grpcRequest.mapStatusException(); advanceRequest(); // Advance to next node before retrying - // Handle INVALID_NODE_ACCOUNT after advancing (matches Go SDK's executionStateRetryWithAnotherNode) + // 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 (matches Go's defer client._UpdateAddressBook()) + // Schedule async address book update client.updateNetworkFromAddressBook(); // Mark this node as unhealthy client.network.increaseBackoff(node); @@ -613,7 +613,19 @@ 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(); @@ -696,7 +708,7 @@ private ProtoRequestT getRequestForExecute() { var request = makeRequest(); // NOTE: advanceRequest() is now called explicitly in the retry logic - // after we determine that a retry is needed, to match Go SDK behavior + // after we determine that a retry is needed // where node advancement happens AFTER error detection, not before return request; @@ -903,12 +915,14 @@ ExecutionState getExecutionState(Status status, ResponseT response) { return ExecutionState.SERVER_ERROR; case BUSY: return ExecutionState.RETRY; - case INVALID_NODE_ACCOUNT: - // Matches Go SDK's executionStateRetryWithAnotherNode behavior: - // immediately retry with next node without delay - // This occurs when a node's account ID has changed - return ExecutionState.SERVER_ERROR; - case OK: + //TODO - use ExecutionState.SUCCESS otherwise there is issue with transaction receipt - raised status INVALID_NODE_ACCOUNT +// case INVALID_NODE_ACCOUNT: +// // Matches Go SDK's executionStateRetryWithAnotherNode behavior: +// // immediately retry with next node without delay +// // This occurs when a node's account ID has changed +// return ExecutionState.SERVER_ERROR; + case INVALID_NODE_ACCOUNT: + case OK: return ExecutionState.SUCCESS; default: return ExecutionState.REQUEST_ERROR; // user error 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 index 98fbb6ac57..2658c607d5 100644 --- 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 @@ -116,6 +116,10 @@ void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() th .setAccountId(newNodeAccountID) .execute(client); + // Add before line 119 in your test + 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); From 875d06f08a22507fbd65b8b6e192733ac9bd9d24 Mon Sep 17 00:00:00 2001 From: emiliyank Date: Thu, 6 Nov 2025 15:33:48 +0200 Subject: [PATCH 08/10] hip-1299 add more integration tests Signed-off-by: emiliyank --- .../java/com/hedera/hashgraph/sdk/Client.java | 81 ++-- .../com/hedera/hashgraph/sdk/Executable.java | 18 +- .../NodeUpdateAccountIdIntegrationTest.java | 432 +++++++++++++++++- 3 files changed, 459 insertions(+), 72 deletions(-) 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 6bc73385a9..26034d387e 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java @@ -473,40 +473,6 @@ public synchronized Client setMirrorNetwork(List network) throws Interru return this; } - /** - * Trigger an immediate address book update. - * This will fetch the address book even if periodic updates are disabled. - */ - 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; - } - private synchronized void scheduleNetworkUpdate(@Nullable Duration delay) { if (delay == null) { networkUpdateFuture = null; @@ -1445,26 +1411,33 @@ public synchronized Client setNetworkUpdatePeriod(Duration networkUpdatePeriod) * * @return {@code this} */ -// public synchronized Client updateNetworkFromAddressBook() { -//// scheduleNetworkUpdate(Duration.ZERO); -// updateNetworkFromAddressBook2(); -// -// if (networkUpdateFuture != null) { -// try { -// networkUpdateFuture.get(30, TimeUnit.SECONDS); -// logger.debug("Address book update completed successfully"); -// } catch (TimeoutException e) { -// logger.warn("Address book update timed out after 30 seconds", e); -// } catch (InterruptedException e) { -// Thread.currentThread().interrupt(); -// logger.warn("Address book update was interrupted", e); -// } catch (ExecutionException e) { -// logger.warn("Address book update failed", e); -// } -// } -// -// return 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 b8f385919d..6eca34aef3 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java @@ -613,11 +613,10 @@ void setNodesFromNodeAccountIds(Client client) { if (nodeAccountIds.size() == 1) { var nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0)); if (nodeProxies == null || nodeProxies.isEmpty()) { - logger.warn("Node {} not found in network, fetching latest address book", - nodeAccountIds.get(0)); + logger.warn("Node {} not found in network, fetching latest address book", nodeAccountIds.get(0)); try { - client.updateNetworkFromAddressBook(); // Synchronous update + client.updateNetworkFromAddressBook(); // Synchronous update nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0)); } catch (Exception e) { logger.error("Failed to update address book", e); @@ -915,14 +914,11 @@ ExecutionState getExecutionState(Status status, ResponseT response) { return ExecutionState.SERVER_ERROR; case BUSY: return ExecutionState.RETRY; - //TODO - use ExecutionState.SUCCESS otherwise there is issue with transaction receipt - raised status INVALID_NODE_ACCOUNT -// case INVALID_NODE_ACCOUNT: -// // Matches Go SDK's executionStateRetryWithAnotherNode behavior: -// // immediately retry with next node without delay -// // This occurs when a node's account ID has changed -// return ExecutionState.SERVER_ERROR; - case INVALID_NODE_ACCOUNT: - case OK: + 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: return ExecutionState.REQUEST_ERROR; // user error 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 index 2658c607d5..0b6194aa9a 100644 --- 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 @@ -3,13 +3,16 @@ 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; @@ -28,23 +31,23 @@ class NodeUpdateAccountIdIntegrationTest { @Test @Disabled - @DisplayName("NodeUpdateTransaction should succeed when updating account ID with proper signatures") + @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:50212", new AccountId(0, 0, 4)); + network.put("localhost:51211", new AccountId(0, 0, 4)); try (var client = Client.forNetwork(network) .setMirrorNetwork(List.of("localhost:5600")) - .setTransportSecurity(false) - .setVerifyCertificates(false)) { + .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 + // 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() @@ -84,7 +87,40 @@ void shouldSucceedWhenUpdatingNodeAccountIdWithProperSignatures() throws Excepti } @Test - @DisplayName("NodeUpdateTransaction can change node account ID, update address book, and retry automatically") + @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(); @@ -116,7 +152,6 @@ void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() th .setAccountId(newNodeAccountID) .execute(client); - // Add before line 119 in your test System.out.println("Transaction node: " + resp.nodeId); System.out.println("Receipt query nodes: " + resp.getReceiptQuery().getNodeAccountIds()); System.out.println("Client network: " + client.getNetwork()); @@ -166,4 +201,387 @@ void testNodeUpdateTransactionCanChangeNodeAccountUpdateAddressbookAndRetry() th 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); + } + } } From 3de7d2e34ee319aa36bb4e4f260431daa22c92e8 Mon Sep 17 00:00:00 2001 From: emiliyank Date: Fri, 7 Nov 2025 12:22:06 +0200 Subject: [PATCH 09/10] hip-1299 use separate CI only for NodeUpdateAccountIdIntegrationTest Signed-off-by: emiliyank --- .github/workflows/build-2nodes.yml | 201 +++++++++++++++++++++++++++++ .github/workflows/build.yml | 15 ++- 2 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build-2nodes.yml diff --git a/.github/workflows/build-2nodes.yml b/.github/workflows/build-2nodes.yml new file mode 100644 index 0000000000..c36df6df5b --- /dev/null +++ b/.github/workflows/build-2nodes.yml @@ -0,0 +1,201 @@ +# SPDX-License-Identifier: Apache-2.0 +name: PR Checks + +on: + push: + branches: + - main + - develop + - release/* + pull_request: + branches: + - main + - develop + - release/* + +defaults: + run: + shell: bash + +permissions: + contents: read + packages: write + +env: + LC_ALL: C.UTF-8 + GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} + GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} + CG_EXEC: ionice -c 2 -n 2 nice -n 19 + +jobs: + build: + name: Build + runs-on: hiero-client-sdk-linux-medium + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: "0" + + - name: Setup NodeJS + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 18 + + - name: Setup Java + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: "21.0.6" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + with: + cache-read-only: false + + - name: Build SDK & Javadoc + id: gradle-build + run: ./gradlew assemble + + - name: Setup Snyk + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + if: >- + ${{ + steps.gradle-build.conclusion == 'success' && + ( + github.event.pull_request.head.repo.full_name == github.repository || + github.event_name == 'push' + ) && + !cancelled() + }} + run: ${CG_EXEC} npm install -g snyk snyk-to-html @wcj/html-to-markdown-cli + + - name: Snyk Scan + id: snyk + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + if: >- + ${{ + steps.gradle-build.conclusion == 'success' && + ( + github.event.pull_request.head.repo.full_name == github.repository || + github.event_name == 'push' + ) && + !cancelled() + }} + run: ${CG_EXEC} snyk test --all-projects '--configuration-matching=^(?!_internal-).*' --severity-threshold=high --policy-path=.snyk --json-file-output=snyk-test.json --org=hiero-client-sdks + + - name: Snyk Code + id: snyk-code + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + if: >- + ${{ + steps.gradle-build.conclusion == 'success' && + ( + github.event.pull_request.head.repo.full_name == github.repository || + github.event_name == 'push' + ) && + !cancelled() + }} + run: ${CG_EXEC} snyk code test --severity-threshold=high --json-file-output=snyk-code.json --org=hiero-client-sdks --policy-path=.snyk + + - name: Publish Snyk Results + if: >- + ${{ + steps.gradle-build.conclusion == 'success' && + ( + github.event.pull_request.head.repo.full_name == github.repository || + github.event_name == 'push' + ) && + !cancelled() + }} + run: | + if [[ -f "snyk-test.json" && -n "$(cat snyk-test.json | tr -d '[:space:]')" ]]; then + snyk-to-html -i snyk-test.json -o snyk-test.html --summary + html-to-markdown snyk-test.html -o snyk + cat snyk/snyk-test.html.md >> $GITHUB_STEP_SUMMARY + fi + + if [[ -f "snyk-code.json" && -n "$(cat snyk-code.json | tr -d '[:space:]')" ]]; then + snyk-to-html -i snyk-code.json -o snyk-code.html --summary + html-to-markdown snyk-code.html -o snyk + cat snyk/snyk-code.html.md >> $GITHUB_STEP_SUMMARY + fi + + - name: Check Snyk Files + if: ${{ always() }} + run: | + echo "::group::Snyk File List" + ls -lah snyk* || true + echo "::endgroup::" + + echo "::group::Snyk Test Contents" + cat snyk-test.json || true + echo "::endgroup::" + + echo "::group::Snyk Code Contents" + cat snyk-code.json || true + echo "::endgroup::" + + test: + name: Unit and Integration Tests + runs-on: hiero-client-sdk-linux-medium + needs: + - build + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Setup NodeJS + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 18 + + - name: Setup Java + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: "21.0.6" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + + - name: Prepare Hiero Solo + id: solo + uses: hiero-ledger/hiero-solo-action@a5fdd9282834d985ae6928d9920404e026333078 # dual mode + with: + installMirrorNode: true + + - name: Build SDK + run: ./gradlew assemble + + - name: Code Quality Checks + run: ./gradlew qualityCheck :examples:qualityCheck --continue --rerun-tasks + + - name: Run ONLY NodeUpdateAccountIdIntegrationTest + env: + OPERATOR_KEY: "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" + OPERATOR_ID: "0.0.2" + HEDERA_NETWORK: "localhost" + run: | + ./gradlew integrationTest --tests "*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]') }} + uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # v5.0.2 + with: + files: gradle/aggregation/build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61b39435df..1a1167b772 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]') }} From 2a855c827ae5a448e678f511b873dc51e4f0f09a Mon Sep 17 00:00:00 2001 From: emiliyank Date: Fri, 7 Nov 2025 12:39:10 +0200 Subject: [PATCH 10/10] hip-1299 use single build.yml file Signed-off-by: emiliyank --- .github/workflows/build-2nodes.yml | 201 ----------------------------- .github/workflows/build.yml | 44 +++++++ 2 files changed, 44 insertions(+), 201 deletions(-) delete mode 100644 .github/workflows/build-2nodes.yml diff --git a/.github/workflows/build-2nodes.yml b/.github/workflows/build-2nodes.yml deleted file mode 100644 index c36df6df5b..0000000000 --- a/.github/workflows/build-2nodes.yml +++ /dev/null @@ -1,201 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -name: PR Checks - -on: - push: - branches: - - main - - develop - - release/* - pull_request: - branches: - - main - - develop - - release/* - -defaults: - run: - shell: bash - -permissions: - contents: read - packages: write - -env: - LC_ALL: C.UTF-8 - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - CG_EXEC: ionice -c 2 -n 2 nice -n 19 - -jobs: - build: - name: Build - runs-on: hiero-client-sdk-linux-medium - steps: - - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - - name: Checkout Code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: "0" - - - name: Setup NodeJS - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 18 - - - name: Setup Java - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - with: - distribution: temurin - java-version: "21.0.6" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 - with: - cache-read-only: false - - - name: Build SDK & Javadoc - id: gradle-build - run: ./gradlew assemble - - - name: Setup Snyk - env: - SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - if: >- - ${{ - steps.gradle-build.conclusion == 'success' && - ( - github.event.pull_request.head.repo.full_name == github.repository || - github.event_name == 'push' - ) && - !cancelled() - }} - run: ${CG_EXEC} npm install -g snyk snyk-to-html @wcj/html-to-markdown-cli - - - name: Snyk Scan - id: snyk - env: - SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - if: >- - ${{ - steps.gradle-build.conclusion == 'success' && - ( - github.event.pull_request.head.repo.full_name == github.repository || - github.event_name == 'push' - ) && - !cancelled() - }} - run: ${CG_EXEC} snyk test --all-projects '--configuration-matching=^(?!_internal-).*' --severity-threshold=high --policy-path=.snyk --json-file-output=snyk-test.json --org=hiero-client-sdks - - - name: Snyk Code - id: snyk-code - env: - SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - if: >- - ${{ - steps.gradle-build.conclusion == 'success' && - ( - github.event.pull_request.head.repo.full_name == github.repository || - github.event_name == 'push' - ) && - !cancelled() - }} - run: ${CG_EXEC} snyk code test --severity-threshold=high --json-file-output=snyk-code.json --org=hiero-client-sdks --policy-path=.snyk - - - name: Publish Snyk Results - if: >- - ${{ - steps.gradle-build.conclusion == 'success' && - ( - github.event.pull_request.head.repo.full_name == github.repository || - github.event_name == 'push' - ) && - !cancelled() - }} - run: | - if [[ -f "snyk-test.json" && -n "$(cat snyk-test.json | tr -d '[:space:]')" ]]; then - snyk-to-html -i snyk-test.json -o snyk-test.html --summary - html-to-markdown snyk-test.html -o snyk - cat snyk/snyk-test.html.md >> $GITHUB_STEP_SUMMARY - fi - - if [[ -f "snyk-code.json" && -n "$(cat snyk-code.json | tr -d '[:space:]')" ]]; then - snyk-to-html -i snyk-code.json -o snyk-code.html --summary - html-to-markdown snyk-code.html -o snyk - cat snyk/snyk-code.html.md >> $GITHUB_STEP_SUMMARY - fi - - - name: Check Snyk Files - if: ${{ always() }} - run: | - echo "::group::Snyk File List" - ls -lah snyk* || true - echo "::endgroup::" - - echo "::group::Snyk Test Contents" - cat snyk-test.json || true - echo "::endgroup::" - - echo "::group::Snyk Code Contents" - cat snyk-code.json || true - echo "::endgroup::" - - test: - name: Unit and Integration Tests - runs-on: hiero-client-sdk-linux-medium - needs: - - build - steps: - - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - - name: Checkout Code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - - name: Setup NodeJS - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 18 - - - name: Setup Java - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - with: - distribution: temurin - java-version: "21.0.6" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 - - - name: Prepare Hiero Solo - id: solo - uses: hiero-ledger/hiero-solo-action@a5fdd9282834d985ae6928d9920404e026333078 # dual mode - with: - installMirrorNode: true - - - name: Build SDK - run: ./gradlew assemble - - - name: Code Quality Checks - run: ./gradlew qualityCheck :examples:qualityCheck --continue --rerun-tasks - - - name: Run ONLY NodeUpdateAccountIdIntegrationTest - env: - OPERATOR_KEY: "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" - OPERATOR_ID: "0.0.2" - HEDERA_NETWORK: "localhost" - run: | - ./gradlew integrationTest --tests "*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]') }} - uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # v5.0.2 - with: - files: gradle/aggregation/build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a1167b772..4f5320b94e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -201,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