diff --git a/.github/workflows/flow-rust-ci.yaml b/.github/workflows/flow-rust-ci.yaml index cf1a3313c..cc13f6225 100644 --- a/.github/workflows/flow-rust-ci.yaml +++ b/.github/workflows/flow-rust-ci.yaml @@ -114,7 +114,6 @@ jobs: uses: hiero-ledger/hiero-solo-action@6a1a77601cf3e69661fb6880530a4edf656b40d5 # v0.14.0 with: installMirrorNode: true - hieroVersion: v0.65.0 - name: Create env file run: | @@ -129,4 +128,61 @@ jobs: run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y . $HOME/.cargo/env - cargo test --workspace + cargo test --workspace -- --skip node::update + dab-tests: + needs: ['check'] + runs-on: hiero-client-sdk-linux-medium + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + submodules: 'recursive' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # v1 + with: + toolchain: 1.88.0 + + - name: Setup GCC and OpenSSL + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends gcc libc6-dev libc-dev libssl-dev pkg-config openssl + + - name: Install Protoc + uses: step-security/setup-protoc@f6eb248a6510dbb851209febc1bd7981604a52e3 # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup NodeJS + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Prepare Hiero Solo for DAB Tests + id: solo-dab + uses: hiero-ledger/hiero-solo-action@9471711c98a56179def6123e1040ab6c2e668881 # branch: 75-add-support-for-multiple-consensus-nodes + with: + hieroVersion: v0.68.0-rc.1 + installMirrorNode: true + mirrorNodeVersion: v0.142.0 + dualMode: true + + - name: Create env file for DAB Tests + run: | + touch .env + echo TEST_OPERATOR_KEY="${{ steps.solo-dab.outputs.privateKey }}" >> .env + echo TEST_OPERATOR_ID="${{ steps.solo-dab.outputs.accountId }}" >> .env + echo TEST_NETWORK_NAME="localhost" >> .env + echo TEST_RUN_NONFREE="1" >> .env + cat .env + + - name: Run DAB Tests + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . $HOME/.cargo/env + cargo test --test e2e node::update -- --test-threads=1 diff --git a/protobufs/build.rs b/protobufs/build.rs index f8a762d15..97a4ae420 100644 --- a/protobufs/build.rs +++ b/protobufs/build.rs @@ -50,8 +50,16 @@ fn main() -> anyhow::Result<()> { .chain(read_dir(&services_tmp_path.join("auxiliary").join("tss"))?) .filter_map(|entry| { let entry = entry.ok()?; + let path = entry.path(); - entry.file_type().ok()?.is_file().then(|| entry.path()) + // Skip hook-related proto files + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.starts_with("hook_") || file_name.starts_with("lambda_") { + return None; + } + } + + entry.file_type().ok()?.is_file().then(|| path) }) .collect(); @@ -67,6 +75,8 @@ fn main() -> anyhow::Result<()> { let contents = contents.replace("com.hedera.hapi.services.auxiliary.history.", ""); let contents = contents.replace("com.hedera.hapi.services.auxiliary.tss.", ""); let contents = contents.replace("com.hedera.hapi.platform.event.", ""); + let contents = contents.replace("com.hedera.hapi.node.hooks.", ""); + let contents = contents.replace("com.hedera.hapi.node.hooks.legacy.", ""); let contents = remove_unused_types(&contents); @@ -259,6 +269,121 @@ fn remove_useless_comments(path: &Path) -> anyhow::Result<()> { Ok(()) } +// Helper function to comment out entire oneof blocks by name +fn comment_out_oneof(contents: &str, oneof_name: &str) -> String { + let lines: Vec<&str> = contents.lines().collect(); + let mut result = Vec::new(); + let mut i = 0; + let oneof_pattern = format!("oneof {} {{", oneof_name); + + while i < lines.len() { + let line = lines[i].trim(); + if line.starts_with(&oneof_pattern) || line == &format!("oneof {}", oneof_name) { + // Found the start of the oneof, comment it out and track brace depth + let original_line = lines[i]; + let indent = original_line.len() - original_line.trim_start().len(); + let indent_str = &original_line[..indent]; + + let mut depth = 0; + let mut found_opening_brace = line.contains('{'); + + result.push(format!("{}// {}", indent_str, lines[i].trim_start())); + if found_opening_brace { + depth = 1; + } + i += 1; + + // Comment out all lines until we close the oneof block + while i < lines.len() && (depth > 0 || !found_opening_brace) { + let current_line = lines[i]; + let current_indent = current_line.len() - current_line.trim_start().len(); + let current_indent_str = ¤t_line[..current_indent.min(current_line.len())]; + + result.push(format!("{}// {}", current_indent_str, current_line.trim_start())); + + if !found_opening_brace && current_line.trim_start().starts_with('{') { + found_opening_brace = true; + depth = 1; + } + + if found_opening_brace { + for ch in current_line.chars() { + if ch == '{' { + depth += 1; + } else if ch == '}' { + depth -= 1; + } + } + } + + i += 1; + if depth == 0 && found_opening_brace { + break; + } + } + } else { + result.push(lines[i].to_string()); + i += 1; + } + } + + result.join("\n") +} + +// Helper function to comment out entire message blocks +fn comment_out_message(contents: &str, message_name: &str) -> String { + let lines: Vec<&str> = contents.lines().collect(); + let mut result = Vec::new(); + let mut i = 0; + let message_start = format!("message {} {{", message_name); + + while i < lines.len() { + let line = lines[i].trim(); + if line.starts_with(&message_start) || line == &format!("message {}", message_name) { + // Found the start of the message, comment it out and track brace depth + let mut depth = 0; + let mut found_opening_brace = line.contains('{'); + + result.push(format!("// {}", lines[i])); + if found_opening_brace { + depth = 1; + } + i += 1; + + // Comment out all lines until we close the message block + while i < lines.len() && (depth > 0 || !found_opening_brace) { + let current_line = lines[i]; + result.push(format!("// {}", current_line)); + + if !found_opening_brace && current_line.trim_start().starts_with('{') { + found_opening_brace = true; + depth = 1; + } + + if found_opening_brace { + for ch in current_line.chars() { + if ch == '{' { + depth += 1; + } else if ch == '}' { + depth -= 1; + } + } + } + + i += 1; + if depth == 0 && found_opening_brace { + break; + } + } + } else { + result.push(lines[i].to_string()); + i += 1; + } + } + + result.join("\n") +} + // Temporary function to remove unused types in transaction.proto fn remove_unused_types(contents: &str) -> String { let contents = contents.replace( @@ -332,6 +457,64 @@ fn remove_unused_types(contents: &str) -> String { "// com.hedera.hapi.services.auxiliary.hints.CrsPublicationTransactionBody", ); + // Comment out hook-related imports + let contents = contents.replace( + "import \"services/hook_types.proto\";", + "// import \"services/hook_types.proto\";", + ); + + let contents = contents.replace( + "import \"services/hook_dispatch.proto\";", + "// import \"services/hook_dispatch.proto\";", + ); + + let contents = contents.replace( + "import \"services/lambda_sstore.proto\";", + "// import \"services/lambda_sstore.proto\";", + ); + + // Comment out hook transaction bodies in transaction.proto + // Note: After package replacement, these become just the class name + let contents = contents.replace( + "HookDispatchTransactionBody hook_dispatch", + "// HookDispatchTransactionBody hook_dispatch", + ); + + let contents = contents.replace( + "LambdaSStoreTransactionBody lambda_sstore", + "// LambdaSStoreTransactionBody lambda_sstore", + ); + + // Comment out hook creation details in various transaction bodies + // Note: After package replacement, this becomes just "HookCreationDetails" + let contents = contents.replace( + "repeated HookCreationDetails hook_creation_details", + "// repeated HookCreationDetails hook_creation_details", + ); + + // Comment out hook_ids_to_delete field + let contents = contents + .replace("repeated int64 hook_ids_to_delete", "// repeated int64 hook_ids_to_delete"); + + // Comment out HookId references + let contents = contents.replace("proto.HookId", "// proto.HookId"); + let contents = contents.replace("HookId hook_id", "// HookId hook_id"); + + // Comment out entire Hook message blocks + let contents = comment_out_message(&contents, "HookId"); + let contents = comment_out_message(&contents, "HookEntityId"); + let contents = comment_out_message(&contents, "HookCall"); + let contents = comment_out_message(&contents, "EvmHookCall"); + + // Comment out all hook-related oneofs that contain HookCall fields + let contents = comment_out_oneof(&contents, "hook_call"); + let contents = comment_out_oneof(&contents, "sender_allowance_hook_call"); + let contents = comment_out_oneof(&contents, "receiver_allowance_hook_call"); + + // Comment out hook-related enum values in HederaFunctionality + let contents = contents.replace("LambdaSStore = 109;", "// LambdaSStore = 109;"); + let contents = contents.replace("HookDispatch = 110;", "// HookDispatch = 110;"); + contents } diff --git a/protobufs/services b/protobufs/services index 324fa858b..fadd38a6b 160000 --- a/protobufs/services +++ b/protobufs/services @@ -1 +1 @@ -Subproject commit 324fa858bb9e90db12cf25939c1aa0aaf02ec2c9 +Subproject commit fadd38a6b2badec02bee35272f03fe8fafadea00 diff --git a/src/client/mod.rs b/src/client/mod.rs index 5400eeb57..ea7e8d1b7 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -637,6 +637,23 @@ impl Client { }); } + /// Triggers an immediate network update from the address book. + /// Note: This method is not part of the public API and may be changed or removed in future versions. + pub(crate) async fn refresh_network(&self) { + match NodeAddressBookQuery::new() + .execute_mirrornet(self.mirrornet().load().channel(), None) + .await + { + Ok(address_book) => { + log::info!("Successfully updated network address book"); + self.set_network_from_address_book(address_book); + } + Err(e) => { + log::warn!("Failed to update network address book: {e:?}"); + } + } + } + /// Returns the Account ID for the operator. #[must_use] pub fn get_operator_account_id(&self) -> Option { diff --git a/src/client/network/mod.rs b/src/client/network/mod.rs index c1be05e30..4e18ada52 100644 --- a/src/client/network/mod.rs +++ b/src/client/network/mod.rs @@ -377,7 +377,7 @@ impl NetworkData { node_ids = self.node_ids.to_vec(); } - let node_sample_amount = (node_ids.len() + 2) / 3; + let node_sample_amount = node_ids.len(); let node_id_indecies = rand::seq::index::sample(&mut thread_rng(), node_ids.len(), node_sample_amount); diff --git a/src/execute.rs b/src/execute.rs index f8c509445..b642ab5e1 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -127,7 +127,9 @@ pub(crate) trait Execute: ValidateChecksums { fn response_pre_check_status(response: &Self::GrpcResponse) -> crate::Result; } -struct ExecuteContext { +/// The lifetime `'a` represents the lifetime of the borrowed `Client` reference. +/// This ensures the context cannot outlive the client it references. +struct ExecuteContext<'a> { // When `Some` the `transaction_id` will be regenerated when expired. operator_account_id: Option, network: Arc, @@ -135,6 +137,8 @@ struct ExecuteContext { max_attempts: usize, // timeout for a single grpc request. grpc_timeout: Option, + // Reference to the client for triggering network updates + client: &'a Client, } pub(crate) async fn execute( @@ -187,17 +191,21 @@ where operator_account_id, network: client.net().0.load_full(), grpc_timeout: backoff.grpc_timeout, + client, }, executable, ) .await } -async fn execute_inner(ctx: &ExecuteContext, executable: &E) -> crate::Result +async fn execute_inner<'a, E>( + ctx: &ExecuteContext<'a>, + executable: &E, +) -> crate::Result where E: Execute + Sync, { - fn recurse_ping(ctx: &ExecuteContext, index: usize) -> BoxFuture<'_, bool> { + fn recurse_ping<'a, 'b: 'a>(ctx: &'b ExecuteContext<'a>, index: usize) -> BoxFuture<'b, bool> { Box::pin(async move { let ctx = ExecuteContext { operator_account_id: None, @@ -205,6 +213,7 @@ where backoff_config: ctx.backoff_config.clone(), max_attempts: ctx.max_attempts, grpc_timeout: ctx.grpc_timeout, + client: ctx.client, }; let ping_query = PingQuery::new(ctx.network.node_ids()[index]); @@ -350,8 +359,8 @@ fn map_tonic_error( } } -async fn execute_single( - ctx: &ExecuteContext, +async fn execute_single<'a, E: Execute + Sync>( + ctx: &ExecuteContext<'a>, executable: &E, node_index: usize, transaction_id: &mut Option, @@ -449,6 +458,34 @@ async fn execute_single( ))) } + Status::InvalidNodeAccount => { + // The node account is invalid or doesn't match the submitted node + // Mark the node as unhealthy and retry with backoff + // This typically indicates the address book is out of date + ctx.network.mark_node_unhealthy(node_index); + + log::warn!( + "Node at index {node_index} / node id {node_account_id} returned {status:?}, marking unhealthy. Updating address book before retry." + ); + + // Update the network address book before retrying, but only if mirror network is configured + if !ctx.client.mirror_network().is_empty() { + ctx.client.refresh_network().await; + log::info!("Address book updated"); + log::info!("network: {:?}", ctx.client.network()); + } else { + log::warn!( + "Cannot update address book: no mirror network configured. Retrying with existing network configuration." + ); + } + + Err(retry::Error::Transient(executable.make_error_pre_check( + status, + transaction_id.as_ref(), + response, + ))) + } + _ if executable.should_retry_pre_check(status) => { // conditional retry on pre-check should back-off and try again Err(retry::Error::Transient(executable.make_error_pre_check( diff --git a/src/fee_schedules.rs b/src/fee_schedules.rs index 4213ed1c4..952cebf18 100644 --- a/src/fee_schedules.rs +++ b/src/fee_schedules.rs @@ -814,6 +814,8 @@ impl FromProtobuf for FeeDataType { SubType::ScheduleCreateContractCall => Self::ScheduleCreateContractCall, SubType::TopicCreateWithCustomFees => Self::TopicCreateWithCustomFees, SubType::SubmitMessageWithCustomFees => Self::SubmitMessageWithCustomFees, + // Hooks are not implemented, map to Default + SubType::CryptoTransferWithHooks => Self::Default, }; Ok(value) diff --git a/src/transaction/execute.rs b/src/transaction/execute.rs index 739713641..a610f5bd0 100644 --- a/src/transaction/execute.rs +++ b/src/transaction/execute.rs @@ -94,6 +94,7 @@ where let signed_transaction = services::SignedTransaction { body_bytes, sig_map: Some(services::SignatureMap { sig_pair: signatures }), + use_serialized_tx_message_hash_algorithm: false, }; let signed_transaction_bytes = signed_transaction.encode_to_vec(); diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index c0ae118f2..5513f8820 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -835,6 +835,7 @@ impl Transaction { let signed_transaction = services::SignedTransaction { body_bytes, sig_map: Some(services::SignatureMap { sig_pair: signatures.clone() }), + use_serialized_tx_message_hash_algorithm: false, }; services::Transaction { signed_transaction_bytes: signed_transaction.encode_to_vec(), diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index d37572058..95d9357e9 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -8,6 +8,7 @@ mod ethereum_transaction; mod fee_schedules; mod file; mod network_version_info; +mod node; mod node_address_book; mod prng; /// Resources for various tests. diff --git a/tests/e2e/node/mod.rs b/tests/e2e/node/mod.rs new file mode 100644 index 000000000..9dcce9297 --- /dev/null +++ b/tests/e2e/node/mod.rs @@ -0,0 +1 @@ +mod update; diff --git a/tests/e2e/node/update.rs b/tests/e2e/node/update.rs new file mode 100644 index 000000000..15d160c5a --- /dev/null +++ b/tests/e2e/node/update.rs @@ -0,0 +1,475 @@ +use std::collections::HashMap; + +use hedera::{ + AccountCreateTransaction, + AccountDeleteTransaction, + AccountId, + Client, + Hbar, + NodeAddressBookQuery, + NodeUpdateTransaction, + PrivateKey, + ServiceEndpoint, + Status, +}; + +/// Helper function to setup client with local DAB tests configuration +fn setup_dab_tests_client() -> Client { + let mut network = HashMap::new(); + network.insert("127.0.0.1:50211".to_string(), AccountId::new(0, 0, 3)); + network.insert("127.0.0.1:51211".to_string(), AccountId::new(0, 0, 4)); + + let client = Client::for_network(network).unwrap(); + client.set_mirror_network(vec!["127.0.0.1:5600".to_string()]); + + // Set the operator to be account 0.0.2 + let operator_account_id = AccountId::new(0, 0, 2); + let operator_key = PrivateKey::from_str_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137", + ) + .unwrap(); + + client.set_operator(operator_account_id, operator_key); + client +} + +#[tokio::test] +async fn can_execute_node_update_transaction() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + let response = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .description("testUpdated") + .decline_reward(true) + .grpc_proxy_endpoint(ServiceEndpoint { + ip_address_v4: None, + port: 123456, + domain_name: "testWebUpdatedsdfsdfsdfsdf.com".to_owned(), + }) + .execute(&client) + .await?; + + let receipt = response.get_receipt(&client).await?; + assert_eq!(receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn can_delete_grpc_web_proxy_endpoint() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + let response = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .delete_grpc_proxy_endpoint() + .execute(&client) + .await?; + + let receipt = response.get_receipt(&client).await?; + assert_eq!(receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn can_change_node_account_id_and_revert_back() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + // Change node account ID from 0.0.8 to 0.0.2 + let response1 = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .account_id(AccountId::new(0, 0, 2)) + .execute(&client) + .await?; + + let receipt1 = response1.get_receipt(&client).await?; + assert_eq!(receipt1.status, Status::Success); + + // Revert the ID back to 0.0.8 + let response2 = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .account_id(AccountId::new(0, 0, 3)) + .execute(&client) + .await?; + + let receipt2 = response2.get_receipt(&client).await?; + assert_eq!(receipt2.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn fails_with_invalid_signature_when_updating_without_admin_key() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + // Create a new account to be the operator + let new_operator_key = PrivateKey::generate_ed25519(); + let create_resp = AccountCreateTransaction::new() + .set_key_without_alias(new_operator_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .initial_balance(Hbar::new(2)) + .execute(&client) + .await?; + + let create_receipt = create_resp.get_receipt(&client).await?; + let new_operator = create_receipt.account_id.unwrap(); + + // Set the new account as operator + client.set_operator(new_operator, new_operator_key); + + // Try to update node account ID without admin key signature + let res = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .description("testUpdated") + .account_id(AccountId::new(0, 0, 50)) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches::assert_matches!( + res, + Err(hedera::Error::ReceiptStatus { status: Status::InvalidSignature, .. }) + ); + + Ok(()) +} + +#[tokio::test] +async fn can_change_node_account_id_to_the_same_account() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + let response = NodeUpdateTransaction::new() + .node_id(1) + .node_account_ids(vec![AccountId::new(0, 0, 3)]) + .account_id(AccountId::new(0, 0, 4)) + .execute(&client) + .await?; + + let receipt = response.get_receipt(&client).await?; + assert_eq!(receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn fails_when_changing_to_non_existent_account_id() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + let res = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .description("testUpdated") + .account_id(AccountId::new(0, 0, 999999999)) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches::assert_matches!( + res, + Err(hedera::Error::ReceiptStatus { status: Status::InvalidSignature, .. }) + ); + + Ok(()) +} + +#[tokio::test] +async fn fails_when_changing_node_account_id_without_account_key() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + // Create a new account + let new_key = PrivateKey::generate_ed25519(); + let create_resp = AccountCreateTransaction::new() + .set_key_without_alias(new_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .initial_balance(Hbar::new(2)) + .execute(&client) + .await?; + + let create_receipt = create_resp.get_receipt(&client).await?; + let new_node_account_id = create_receipt.account_id.unwrap(); + + // Try to set node account ID to new account without signing with new account's key + let res = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .description("testUpdated") + .account_id(new_node_account_id) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches::assert_matches!( + res, + Err(hedera::Error::ReceiptStatus { status: Status::InvalidSignature, .. }) + ); + + Ok(()) +} + +#[tokio::test] +async fn fails_when_changing_to_deleted_account_id() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + // Create a new account + let new_account_key = PrivateKey::generate_ed25519(); + let create_resp = AccountCreateTransaction::new() + .set_key_without_alias(new_account_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .execute(&client) + .await?; + + let create_receipt = create_resp.get_receipt(&client).await?; + let new_account = create_receipt.account_id.unwrap(); + + // Delete the account + let delete_resp = AccountDeleteTransaction::new() + .account_id(new_account) + .transfer_account_id(client.get_operator_account_id().unwrap()) + .freeze_with(&client)? + .sign(new_account_key.clone()) + .execute(&client) + .await?; + + delete_resp.get_receipt(&client).await?; + + // Try to set node account ID to deleted account + let res = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .description("testUpdated") + .account_id(new_account) + .freeze_with(&client)? + .sign(new_account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches::assert_matches!( + res, + Err(hedera::Error::ReceiptStatus { status: Status::AccountDeleted, .. }) + ); + + Ok(()) +} + +#[tokio::test] +async fn fails_when_new_node_account_has_zero_balance() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + // Create a new account with zero balance + let new_account_key = PrivateKey::generate_ed25519(); + let create_resp = AccountCreateTransaction::new() + .set_key_without_alias(new_account_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .execute(&client) + .await?; + + let create_receipt = create_resp.get_receipt(&client).await?; + let new_account = create_receipt.account_id.unwrap(); + + // Try to set node account ID to account with zero balance + let res = NodeUpdateTransaction::new() + .node_id(0) + .description("testUpdated") + .account_id(new_account) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .freeze_with(&client)? + .sign(new_account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + // Should fail with status code 526 (NODE_ACCOUNT_HAS_ZERO_BALANCE) + assert_matches::assert_matches!( + res, + Err(hedera::Error::ReceiptStatus { status: Status::NodeAccountHasZeroBalance, .. }) + ); + + Ok(()) +} + +#[tokio::test] +async fn updates_addressbook_and_retries_after_node_account_id_change() -> anyhow::Result<()> { + let client = setup_dab_tests_client(); + + // Create the account that will be the new node account ID + let new_account_key = PrivateKey::generate_ed25519(); + let create_resp = AccountCreateTransaction::new() + .set_key_without_alias(new_account_key.public_key()) + .initial_balance(Hbar::new(1)) + .execute(&client) + .await?; + + let create_receipt = create_resp.get_receipt(&client).await?; + let new_node_account_id = create_receipt.account_id.unwrap(); + + // Update node account ID (0.0.4 -> new_node_account_id) + let update_resp = NodeUpdateTransaction::new() + .node_id(1) + .account_id(new_node_account_id) + .node_account_ids(vec![AccountId::new(0, 0, 3)]) + .freeze_with(&client)? + .sign(new_account_key) + .execute(&client) + .await?; + + update_resp.get_receipt(&client).await?; + + // Wait for mirror node to import data + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + let another_new_key = PrivateKey::generate_ed25519(); + + // Submit to the updated node - should trigger addressbook refresh + let test_resp = AccountCreateTransaction::new() + .set_key_without_alias(another_new_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 4), AccountId::new(0, 0, 3)]) + .execute(&client) + .await?; + + let test_receipt = test_resp.get_receipt(&client).await?; + assert_eq!(test_receipt.status, Status::Success); + + // Verify address book has been updated + let network = client.network(); + let has_new_node_account = + network.values().any(|account_id| *account_id == new_node_account_id); + + assert!(has_new_node_account, "Address book should contain the new node account ID"); + + // Check if the new node account has the specific address and apply workaround if needed + let network = client.network(); + let node_address = network + .iter() + .find(|(_, account_id)| **account_id == new_node_account_id) + .map(|(address, _)| address); + + if let Some(address) = node_address { + assert_eq!( + address, + "network-node2-svc.solo.svc.cluster.local:50211", + "Expected node with account {} to have address network-node2-svc.solo.svc.cluster.local:50211", + new_node_account_id + ); + + // Apply workaround: change port from 50211 to 51211 + let mut updated_network = HashMap::new(); + for (addr, acc_id) in network.iter() { + if *acc_id == new_node_account_id { + updated_network + .insert("network-node2-svc.solo.svc.cluster.local:51211".to_string(), *acc_id); + } else { + updated_network.insert(addr.clone(), *acc_id); + } + } + client.set_network(updated_network)?; + } + + // This transaction should succeed with the new node account ID + let final_resp = AccountCreateTransaction::new() + .set_key_without_alias(another_new_key.public_key()) + .node_account_ids(vec![new_node_account_id]) + .execute(&client) + .await?; + + let final_receipt = final_resp.get_receipt(&client).await?; + assert_eq!(final_receipt.status, Status::Success); + + // Revert the node account ID + let revert_resp = NodeUpdateTransaction::new() + .node_id(1) + .node_account_ids(vec![new_node_account_id]) + .account_id(AccountId::new(0, 0, 4)) + .execute(&client) + .await?; + + revert_resp.get_receipt(&client).await?; + + Ok(()) +} + +#[tokio::test] +async fn handles_node_account_id_change_without_mirror_node_setup() -> anyhow::Result<()> { + // Create a client without mirror network + let network_client = setup_dab_tests_client(); + + // Remove mirror network + network_client.set_mirror_network(vec![]); + + // Create the account that will be the new node account ID + let new_account_key = PrivateKey::generate_ed25519(); + let create_resp = AccountCreateTransaction::new() + .set_key_without_alias(new_account_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 3), AccountId::new(0, 0, 4)]) + .initial_balance(Hbar::new(1)) + .execute(&network_client) + .await?; + + let create_receipt = create_resp.get_receipt(&network_client).await?; + let new_node_account_id = create_receipt.account_id.unwrap(); + + // Update node account ID + let update_resp = NodeUpdateTransaction::new() + .node_id(0) + .account_id(new_node_account_id) + .freeze_with(&network_client)? + .sign(new_account_key) + .execute(&network_client) + .await?; + + update_resp.get_receipt(&network_client).await?; + + // Wait for changes to propagate + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + let another_new_key = PrivateKey::generate_ed25519(); + + // Submit transaction - should retry since no mirror node to update addressbook + let test_resp = AccountCreateTransaction::new() + .set_key_without_alias(another_new_key.public_key()) + .node_account_ids(vec![AccountId::new(0, 0, 3), AccountId::new(0, 0, 4)]) + .execute(&network_client) + .await?; + + let test_receipt = test_resp.get_receipt(&network_client).await?; + assert_eq!(test_receipt.status, Status::Success); + + // Verify address book has NOT been updated (no mirror node) + let network = network_client.network(); + let node1 = network.iter().find(|(_, account_id)| **account_id == AccountId::new(0, 0, 3)); + let node2 = network.iter().find(|(_, account_id)| **account_id == AccountId::new(0, 0, 4)); + + assert!(node1.is_some(), "Node 0.0.3 should still be in the network state"); + assert!(node2.is_some(), "Node 0.0.4 should still be in the network state"); + + // This transaction should succeed with retries + let final_resp = AccountCreateTransaction::new() + .set_key_without_alias(another_new_key.public_key()) + .execute(&network_client) + .await?; + + let final_receipt = final_resp.get_receipt(&network_client).await?; + assert_eq!(final_receipt.status, Status::Success); + + // Revert the node account ID + let revert_resp = NodeUpdateTransaction::new() + .node_id(0) + .node_account_ids(vec![AccountId::new(0, 0, 4)]) + .account_id(AccountId::new(0, 0, 3)) + .execute(&network_client) + .await?; + + revert_resp.get_receipt(&network_client).await?; + + Ok(()) +}