Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f8cf1a6
initial logic for changing node account id
emiliyank Oct 21, 2025
0c6c579
remove client property from GrpcRequest
emiliyank Oct 21, 2025
b04c0c2
add NodeUpdateAccountIdIntegrationTest
emiliyank Oct 26, 2025
017dd5e
hip 1299 - in Executable handle status INVALID_NODE_ACCOUNT_ID
emiliyank Nov 3, 2025
c25d9cd
hip 1299 - add integration test to update AddressBook
emiliyank Nov 3, 2025
9bcfbd3
hip 1299 - switch error from INVALID_NODE_ACCOUNT_ID to INVALID_NODE_…
emiliyank Nov 4, 2025
ba6351c
Merge branch 'main' into feature/hip-1299-changing-node-account-id
emiliyank Nov 5, 2025
c78f359
hip 1299 - use new method Client.updateNetworkFromAddressBook()
emiliyank Nov 6, 2025
ae7dcc3
Merge remote-tracking branch 'origin/feature/hip-1299-changing-node-a…
emiliyank Nov 6, 2025
875d06f
hip-1299 add more integration tests
emiliyank Nov 6, 2025
0f35473
initial logic for changing node account id
emiliyank Oct 21, 2025
d6edab7
remove client property from GrpcRequest
emiliyank Oct 21, 2025
a2c75ff
add NodeUpdateAccountIdIntegrationTest
emiliyank Oct 26, 2025
7b273cb
hip 1299 - in Executable handle status INVALID_NODE_ACCOUNT_ID
emiliyank Nov 3, 2025
99810ac
hip 1299 - add integration test to update AddressBook
emiliyank Nov 3, 2025
cd3870e
hip 1299 - switch error from INVALID_NODE_ACCOUNT_ID to INVALID_NODE_…
emiliyank Nov 4, 2025
b7388d4
hip 1299 - use new method Client.updateNetworkFromAddressBook()
emiliyank Nov 6, 2025
84474ac
hip-1299 add more integration tests
emiliyank Nov 6, 2025
e1f2dce
move all dab tests to NodeUpdateTransactionIntegrationTest
emiliyank Nov 11, 2025
06d46a9
Merge remote-tracking branch 'origin/feature/hip-1299-changing-node-a…
emiliyank Nov 11, 2025
6249fdd
remove unused class NodeUpdateAccountIdIntegrationTest
emiliyank Nov 11, 2025
63c79e9
use consensus node v0.68 on solo-action
emiliyank Nov 12, 2025
2ebecb1
add --rerun-tasks on gradlew
emiliyank Nov 13, 2025
f60ea70
add --rerun-tasks on :aggregation:testCodeCoverageReport
emiliyank Nov 13, 2025
76c8add
revert unused changes in Executable
emiliyank Nov 13, 2025
3064770
Merge branch 'main' into feature/hip-1299-changing-node-account-id
emiliyank Dec 1, 2025
073a3b3
fix: advance node on server error in async execution
emiliyank Dec 2, 2025
cda1929
refactor NodeUpdateTransactionIntegrationTest to fix Codacy warning
emiliyank Dec 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
54 changes: 41 additions & 13 deletions sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@

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:
Expand Down Expand Up @@ -767,7 +767,7 @@

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:
Expand Down Expand Up @@ -868,6 +868,7 @@
case PLATFORM_NOT_ACTIVE:
return ExecutionState.SERVER_ERROR;
case BUSY:
case INVALID_NODE_ACCOUNT_ID:
return ExecutionState.RETRY;
case OK:
return ExecutionState.SUCCESS;
Expand Down Expand Up @@ -973,8 +974,27 @@
return Executable.this.mapResponse(response, node.getAccountId(), request);
}

void handleResponse(ResponseT response, Status status, ExecutionState executionState) {
node.decreaseBackoff();
void handleResponse(ResponseT response, Status status, ExecutionState executionState, @Nullable Client client) {

Check warning on line 977 in sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java#L977

Method GrpcRequest::handleResponse has 57 lines of code (limit is 50)

Check warning on line 977 in sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java#L977

Method GrpcRequest::handleResponse has a cyclomatic complexity of 9 (limit is 8)
// 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 {
node.decreaseBackoff();
}

this.response = Executable.this.responseListener.apply(response);
this.responseStatus = status;
Expand All @@ -993,19 +1013,27 @@
}
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 -> {}
}
}
Expand Down
63 changes: 63 additions & 0 deletions sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<T extends Transaction<T>>
extends Executable<
T,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, AccountId>();
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();
}
}
}