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..53aa33c9c 100644 --- a/protobufs/build.rs +++ b/protobufs/build.rs @@ -14,6 +14,37 @@ use regex::RegexBuilder; const DERIVE_EQ_HASH: &str = "#[derive(Eq, Hash)]"; const SERVICES_FOLDER: &str = "./services/hapi/hedera-protobuf-java-api/src/main/proto/services"; +// Recursively find all .proto files, excluding state/ and auxiliary/ subdirectories +fn find_proto_files(dir: &Path) -> anyhow::Result> { + let mut files = Vec::new(); + for entry in read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + // Skip state/ directory (internal node state, not for SDK) + // Include auxiliary/tss/ but skip other auxiliary/ subdirectories + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if dir_name == "state" { + continue; // Skip state directory entirely + } else if dir_name == "auxiliary" { + // Only include auxiliary/tss files + let tss_path = path.join("tss"); + if tss_path.is_dir() { + files.extend(find_proto_files(&tss_path)?); + } + continue; + } + + files.extend(find_proto_files(&path)?); + } else if path.extension().and_then(|s| s.to_str()) == Some("proto") { + files.push(path); + } + } + Ok(files) +} + fn main() -> anyhow::Result<()> { // services is the "base" module for the hedera protobufs // in the beginning, there was only services and it was named "protos" @@ -46,14 +77,7 @@ fn main() -> anyhow::Result<()> { )?; fs::rename(out_path.join("services"), &services_tmp_path)?; - let services: Vec<_> = read_dir(&services_tmp_path)? - .chain(read_dir(&services_tmp_path.join("auxiliary").join("tss"))?) - .filter_map(|entry| { - let entry = entry.ok()?; - - entry.file_type().ok()?.is_file().then(|| entry.path()) - }) - .collect(); + let services = find_proto_files(&services_tmp_path)?; // iterate through each file let re_package = RegexBuilder::new(r"^package (.*);$").multi_line(true).build()?; @@ -67,6 +91,7 @@ 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 = remove_unused_types(&contents); @@ -93,7 +118,6 @@ fn main() -> anyhow::Result<()> { .type_attribute("proto.ContractID.contract", DERIVE_EQ_HASH) .type_attribute("proto.TransactionID", DERIVE_EQ_HASH) .type_attribute("proto.Timestamp", DERIVE_EQ_HASH) - .type_attribute("proto.NftTransfer", DERIVE_EQ_HASH) .type_attribute("proto.Fraction", DERIVE_EQ_HASH) .type_attribute("proto.TopicID", DERIVE_EQ_HASH) .type_attribute("proto.TokenID", DERIVE_EQ_HASH) @@ -112,7 +136,14 @@ fn main() -> anyhow::Result<()> { .type_attribute("proto.TokenAllowance", DERIVE_EQ_HASH) .type_attribute("proto.GrantedCryptoAllowance", DERIVE_EQ_HASH) .type_attribute("proto.GrantedTokenAllowance", DERIVE_EQ_HASH) - .type_attribute("proto.Duration", DERIVE_EQ_HASH); + .type_attribute("proto.Duration", DERIVE_EQ_HASH) + .type_attribute("proto.HookCall", DERIVE_EQ_HASH) + .type_attribute("proto.HookCall.call_spec", DERIVE_EQ_HASH) + .type_attribute("proto.HookCall.id", DERIVE_EQ_HASH) + .type_attribute("proto.HookId", DERIVE_EQ_HASH) + .type_attribute("proto.HookEntityId", DERIVE_EQ_HASH) + .type_attribute("proto.HookEntityId.entity_id", DERIVE_EQ_HASH) + .type_attribute("proto.EvmHookCall", DERIVE_EQ_HASH); // the ResponseCodeEnum should be marked as #[non_exhaustive] so // adding variants does not trigger a breaking change 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/account/account_create_transaction.rs b/src/account/account_create_transaction.rs index 3a28ca82e..110bca6a8 100644 --- a/src/account/account_create_transaction.rs +++ b/src/account/account_create_transaction.rs @@ -5,6 +5,10 @@ use hedera_proto::services::crypto_service_client::CryptoServiceClient; use time::Duration; use tonic::transport::Channel; +use crate::hooks::{ + HookCreationDetails, + LambdaEvmHook, +}; use crate::ledger_id::RefLedgerId; use crate::protobuf::{ FromProtobuf, @@ -76,6 +80,9 @@ pub struct AccountCreateTransactionData { /// If true, the account declines receiving a staking reward. The default value is false. decline_staking_reward: bool, + + /// Hooks to add immediately after creating this account. + hooks: Vec, } impl Default for AccountCreateTransactionData { @@ -91,6 +98,7 @@ impl Default for AccountCreateTransactionData { alias: None, staked_id: None, decline_staking_reward: false, + hooks: Vec::new(), } } } @@ -293,6 +301,29 @@ impl AccountCreateTransaction { self.data_mut().decline_staking_reward = decline; self } + + pub fn add_hook(&mut self, hook: HookCreationDetails) -> &mut Self { + self.data_mut().hooks.push(hook); + self + } + + pub fn add_lambda_evm_hook(&mut self, hook: LambdaEvmHook) -> &mut Self { + // Helper to add a Lambda EVM hook with default extension point and hook ID + use crate::hooks::HookExtensionPoint; + let details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(hook)); + self.data_mut().hooks.push(details); + self + } + + pub fn set_hooks(&mut self, hooks: Vec) -> &mut Self { + self.data_mut().hooks = hooks; + self + } + + pub fn get_hooks(&self) -> &[HookCreationDetails] { + &self.data().hooks + } } impl TransactionData for AccountCreateTransactionData {} @@ -353,6 +384,11 @@ impl FromProtobuf for AccountCreateTransa alias, staked_id: Option::from_protobuf(pb.staked_id)?, decline_staking_reward: pb.decline_reward, + hooks: pb + .hook_creation_details + .into_iter() + .map(HookCreationDetails::from_protobuf) + .collect::, _>>()?, }) } } @@ -391,6 +427,7 @@ impl ToProtobuf for AccountCreateTransactionData { alias: self.alias.map_or(vec![], |it| it.to_bytes().to_vec()), decline_reward: self.decline_staking_reward, staked_id, + hook_creation_details: self.hooks.iter().map(|h| h.to_protobuf()).collect(), } } } @@ -417,8 +454,13 @@ mod tests { AccountCreateTransaction, AccountId, AnyTransaction, + ContractId, EvmAddress, + EvmHookSpec, Hbar, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, PublicKey, }; @@ -560,6 +602,7 @@ mod tests { 20, 23, ], + hook_creation_details: [], staked_id: Some( StakedAccountId( AccountId { @@ -683,6 +726,7 @@ mod tests { 20, 23, ], + hook_creation_details: [], staked_id: Some( StakedNodeId( 4, @@ -710,6 +754,13 @@ mod tests { #[test] fn from_proto_body() { #[allow(deprecated)] + let contract_id = ContractId::new(0, 0, 1); + let hooks = vec![HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 0, + Some(LambdaEvmHook::new(EvmHookSpec::new(Some(contract_id)), vec![])), + )]; + let tx = services::CryptoCreateTransactionBody { key: Some(key().to_protobuf()), initial_balance: INITIAL_BALANCE.to_tinybars() as u64, @@ -728,6 +779,7 @@ mod tests { staked_id: Some(services::crypto_create_transaction_body::StakedId::StakedAccountId( STAKED_ACCOUNT_ID.to_protobuf(), )), + hook_creation_details: hooks.iter().map(|h| h.to_protobuf()).collect(), }; let tx = AccountCreateTransactionData::from_protobuf(tx).unwrap(); diff --git a/src/account/account_update_transaction.rs b/src/account/account_update_transaction.rs index 84f555bbe..004519631 100644 --- a/src/account/account_update_transaction.rs +++ b/src/account/account_update_transaction.rs @@ -8,6 +8,7 @@ use time::{ }; use tonic::transport::Channel; +use crate::hooks::HookCreationDetails; use crate::ledger_id::RefLedgerId; use crate::protobuf::{ FromProtobuf, @@ -89,6 +90,10 @@ pub struct AccountUpdateTransactionData { /// If true, the account declines receiving a staking reward. The default value is false. decline_staking_reward: Option, + + /// Hooks to add immediately after updating this account. + hooks: Vec, + hook_ids_to_delete: Vec, } impl AccountUpdateTransaction { @@ -271,6 +276,42 @@ impl AccountUpdateTransaction { self.data_mut().decline_staking_reward = Some(decline); self } + + /// Returns the hooks to be created. + #[must_use] + pub fn get_hooks_to_create(&self) -> &[HookCreationDetails] { + &self.data().hooks + } + + /// Adds a hook to be created. + pub fn add_hook(&mut self, hook: HookCreationDetails) -> &mut Self { + self.data_mut().hooks.push(hook); + self + } + + /// Sets the hooks to be created. + pub fn set_hooks(&mut self, hooks: Vec) -> &mut Self { + self.data_mut().hooks = hooks; + self + } + + /// Returns the hook IDs to be deleted. + #[must_use] + pub fn get_hooks_to_delete(&self) -> &[i64] { + &self.data().hook_ids_to_delete + } + + /// Adds a hook ID to be deleted. + pub fn delete_hook(&mut self, hook_id: i64) -> &mut Self { + self.data_mut().hook_ids_to_delete.push(hook_id); + self + } + + /// Sets the hook IDs to be deleted. + pub fn delete_hooks(&mut self, hook_ids: Vec) -> &mut Self { + self.data_mut().hook_ids_to_delete = hook_ids; + self + } } impl TransactionData for AccountUpdateTransactionData {} @@ -339,6 +380,12 @@ impl FromProtobuf for AccountUpdateTransa max_automatic_token_associations: pb.max_automatic_token_associations, staked_id: Option::from_protobuf(pb.staked_id)?, decline_staking_reward: pb.decline_reward, + hooks: pb + .hook_creation_details + .into_iter() + .map(HookCreationDetails::from_protobuf) + .collect::, _>>()?, + hook_ids_to_delete: pb.hook_ids_to_delete, }) } } @@ -382,6 +429,8 @@ impl ToProtobuf for AccountUpdateTransactionData { receive_record_threshold_field: None, receiver_sig_required_field: receiver_signature_required, staked_id, + hook_creation_details: self.hooks.iter().map(|hook| hook.to_protobuf()).collect(), + hook_ids_to_delete: self.hook_ids_to_delete.clone(), } } } @@ -410,6 +459,11 @@ mod tests { AccountId, AccountUpdateTransaction, AnyTransaction, + ContractId, + EvmHookSpec, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, PublicKey, }; @@ -563,6 +617,8 @@ mod tests { 100, ), decline_reward: None, + hook_ids_to_delete: [], + hook_creation_details: [], send_record_threshold_field: None, receive_record_threshold_field: None, receiver_sig_required_field: Some( @@ -696,6 +752,8 @@ mod tests { 100, ), decline_reward: None, + hook_ids_to_delete: [], + hook_creation_details: [], send_record_threshold_field: None, receive_record_threshold_field: None, receiver_sig_required_field: Some( @@ -730,6 +788,14 @@ mod tests { #[test] fn from_proto_body() { #[allow(deprecated)] + let contract_id = ContractId::new(0, 0, 1); + let hooks = vec![HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 0, + Some(LambdaEvmHook::new(EvmHookSpec::new(Some(contract_id)), vec![])), + )]; + let hook_ids_to_delete = vec![1, 2, 3]; + let tx = services::CryptoUpdateTransactionBody { account_id_to_update: Some(ACCOUNT_ID.to_protobuf()), key: Some(key().to_protobuf()), @@ -746,6 +812,8 @@ mod tests { )), proxy_fraction: 0, expiration_time: Some(EXPIRATION_TIME.to_protobuf()), + hook_creation_details: hooks.iter().map(|h| h.to_protobuf()).collect(), + hook_ids_to_delete, }; let tx = AccountUpdateTransactionData::from_protobuf(tx).unwrap(); 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/contract/contract_create_transaction.rs b/src/contract/contract_create_transaction.rs index dd170ca55..7e9a7618d 100644 --- a/src/contract/contract_create_transaction.rs +++ b/src/contract/contract_create_transaction.rs @@ -5,6 +5,7 @@ use hedera_proto::services::smart_contract_service_client::SmartContractServiceC use time::Duration; use tonic::transport::Channel; +use crate::hooks::HookCreationDetails; use crate::ledger_id::RefLedgerId; use crate::protobuf::FromProtobuf; use crate::staked_id::StakedId; @@ -57,6 +58,9 @@ pub struct ContractCreateTransactionData { staked_id: Option, decline_staking_reward: bool, + + /// Hooks to add immediately after creating this contract. + hooks: Vec, } impl Default for ContractCreateTransactionData { @@ -74,6 +78,7 @@ impl Default for ContractCreateTransactionData { auto_renew_account_id: None, staked_id: None, decline_staking_reward: false, + hooks: Vec::new(), } } } @@ -240,6 +245,24 @@ impl ContractCreateTransaction { self.data_mut().decline_staking_reward = decline; self } + + /// Returns the hooks to be created. + #[must_use] + pub fn get_hooks_to_create(&self) -> &[HookCreationDetails] { + &self.data().hooks + } + + /// Adds a hook to be created. + pub fn add_hook(&mut self, hook: HookCreationDetails) -> &mut Self { + self.data_mut().hooks.push(hook); + self + } + + /// Sets the hooks to be created. + pub fn set_hooks(&mut self, hooks: Vec) -> &mut Self { + self.data_mut().hooks = hooks; + self + } } impl TransactionData for ContractCreateTransactionData { @@ -313,6 +336,11 @@ impl FromProtobuf for ContractCreateTra auto_renew_account_id: Option::from_protobuf(pb.auto_renew_account_id)?, staked_id: Option::from_protobuf(pb.staked_id)?, decline_staking_reward: pb.decline_reward, + hooks: pb + .hook_creation_details + .into_iter() + .map(HookCreationDetails::from_protobuf) + .collect::, _>>()?, }) } } @@ -372,6 +400,7 @@ impl ToProtobuf for ContractCreateTransactionData { decline_reward: self.decline_staking_reward, initcode_source, staked_id, + hook_creation_details: self.hooks.iter().map(|hook| hook.to_protobuf()).collect(), } } } @@ -397,8 +426,13 @@ mod tests { AccountId, AnyTransaction, ContractCreateTransaction, + ContractId, + EvmHookSpec, FileId, Hbar, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, PublicKey, }; @@ -539,6 +573,7 @@ mod tests { }, ), decline_reward: false, + hook_creation_details: [], initcode_source: Some( FileId( FileId { @@ -665,6 +700,7 @@ mod tests { }, ), decline_reward: false, + hook_creation_details: [], initcode_source: Some( Initcode( [ @@ -702,6 +738,13 @@ mod tests { #[test] fn from_proto_body() { #[allow(deprecated)] + let contract_id = ContractId::new(0, 0, 1); + let hooks = vec![HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 0, + Some(LambdaEvmHook::new(EvmHookSpec::new(Some(contract_id)), vec![])), + )]; + let tx = services::ContractCreateTransactionBody { admin_key: Some(admin_key().to_protobuf()), initial_balance: INITIAL_BALANCE.to_tinybars(), @@ -724,6 +767,7 @@ mod tests { BYTECODE_FILE_ID.to_protobuf(), ), ), + hook_creation_details: hooks.iter().map(|h| h.to_protobuf()).collect(), }; let tx = ContractCreateTransactionData::from_protobuf(tx).unwrap(); diff --git a/src/contract/contract_update_transaction.rs b/src/contract/contract_update_transaction.rs index 6d4225fc1..65dbd26cf 100644 --- a/src/contract/contract_update_transaction.rs +++ b/src/contract/contract_update_transaction.rs @@ -8,6 +8,7 @@ use time::{ }; use tonic::transport::Channel; +use crate::hooks::HookCreationDetails; use crate::ledger_id::RefLedgerId; use crate::protobuf::FromProtobuf; use crate::staked_id::StakedId; @@ -55,6 +56,10 @@ pub struct ContractUpdateTransactionData { staked_id: Option, decline_staking_reward: Option, + + /// Hooks to add immediately after updating this contract. + hooks: Vec, + hook_ids_to_delete: Vec, } impl ContractUpdateTransaction { @@ -195,6 +200,42 @@ impl ContractUpdateTransaction { self.data_mut().decline_staking_reward = Some(decline); self } + + /// Returns the hooks to be created. + #[must_use] + pub fn get_hooks_to_create(&self) -> &[HookCreationDetails] { + &self.data().hooks + } + + /// Adds a hook to be created. + pub fn add_hook(&mut self, hook: HookCreationDetails) -> &mut Self { + self.data_mut().hooks.push(hook); + self + } + + /// Sets the hooks to be created. + pub fn set_hooks(&mut self, hooks: Vec) -> &mut Self { + self.data_mut().hooks = hooks; + self + } + + /// Returns the hook IDs to be deleted. + #[must_use] + pub fn get_hooks_to_delete(&self) -> &[i64] { + &self.data().hook_ids_to_delete + } + + /// Adds a hook ID to be deleted. + pub fn delete_hook(&mut self, hook_id: i64) -> &mut Self { + self.data_mut().hook_ids_to_delete.push(hook_id); + self + } + + /// Sets the hook IDs to be deleted. + pub fn delete_hooks(&mut self, hook_ids: Vec) -> &mut Self { + self.data_mut().hook_ids_to_delete = hook_ids; + self + } } impl TransactionData for ContractUpdateTransactionData {} @@ -255,6 +296,12 @@ impl FromProtobuf for ContractUpdateTra proxy_account_id: Option::from_protobuf(pb.proxy_account_id)?, staked_id: Option::from_protobuf(pb.staked_id)?, decline_staking_reward: pb.decline_reward, + hooks: pb + .hook_creation_details + .into_iter() + .map(HookCreationDetails::from_protobuf) + .collect::, _>>()?, + hook_ids_to_delete: pb.hook_ids_to_delete, }) } } @@ -301,6 +348,8 @@ impl ToProtobuf for ContractUpdateTransactionData { staked_id, file_id: None, memo_field, + hook_creation_details: self.hooks.iter().map(|hook| hook.to_protobuf()).collect(), + hook_ids_to_delete: self.hook_ids_to_delete.clone(), } } } @@ -336,6 +385,10 @@ mod tests { AnyTransaction, ContractId, ContractUpdateTransaction, + EvmHookSpec, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, PublicKey, }; @@ -496,6 +549,8 @@ mod tests { }, ), decline_reward: None, + hook_ids_to_delete: [], + hook_creation_details: [], memo_field: Some( MemoWrapper( "3", @@ -635,6 +690,8 @@ mod tests { }, ), decline_reward: None, + hook_ids_to_delete: [], + hook_creation_details: [], memo_field: Some( MemoWrapper( "3", @@ -666,6 +723,14 @@ mod tests { #[test] fn from_proto_body() { + let hooks = vec![HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 0, + Some(LambdaEvmHook::new(EvmHookSpec::new(Some(CONTRACT_ID)), vec![])), + )]; + + let hook_ids_to_delete = vec![1, 2, 3]; + #[allow(deprecated)] let tx = services::ContractUpdateTransactionBody { contract_id: Some(CONTRACT_ID.to_protobuf()), @@ -683,6 +748,8 @@ mod tests { STAKED_ACCOUNT_ID.to_protobuf(), )), file_id: None, + hook_creation_details: hooks.iter().map(|h| h.to_protobuf()).collect(), + hook_ids_to_delete: hook_ids_to_delete, }; let tx = ContractUpdateTransactionData::from_protobuf(tx).unwrap(); 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..b226a7391 100644 --- a/src/fee_schedules.rs +++ b/src/fee_schedules.rs @@ -798,6 +798,9 @@ pub enum FeeDataType { /// The resource prices are scoped to a submit message operation with custom fees. SubmitMessageWithCustomFees, + + /// The resource prices are scoped to a crypto transfer with hooks. + CryptoTransferWithHooks, } impl FromProtobuf for FeeDataType { @@ -814,6 +817,7 @@ impl FromProtobuf for FeeDataType { SubType::ScheduleCreateContractCall => Self::ScheduleCreateContractCall, SubType::TopicCreateWithCustomFees => Self::TopicCreateWithCustomFees, SubType::SubmitMessageWithCustomFees => Self::SubmitMessageWithCustomFees, + SubType::CryptoTransferWithHooks => Self::CryptoTransferWithHooks, }; Ok(value) @@ -836,6 +840,7 @@ impl ToProtobuf for FeeDataType { Self::ScheduleCreateContractCall => SubType::ScheduleCreateContractCall, Self::TopicCreateWithCustomFees => SubType::TopicCreateWithCustomFees, Self::SubmitMessageWithCustomFees => SubType::SubmitMessageWithCustomFees, + Self::CryptoTransferWithHooks => SubType::CryptoTransferWithHooks, } } } diff --git a/src/hooks/evm_hook_call.rs b/src/hooks/evm_hook_call.rs new file mode 100644 index 000000000..3698ceb67 --- /dev/null +++ b/src/hooks/evm_hook_call.rs @@ -0,0 +1,87 @@ +use hedera_proto::services; + +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// An EVM hook call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvmHookCall { + /// The call data for the EVM hook. + pub call_data: Option>, + /// The gas limit for the hook call. + pub gas_limit: Option, +} + +impl EvmHookCall { + /// Create a new `EvmHookCall`. + pub fn new(call_data: Option>) -> Self { + Self { call_data, gas_limit: None } + } + + /// Set the call data for the hook. + pub fn set_call_data(&mut self, call_data: Vec) -> &mut Self { + self.call_data = Some(call_data); + self + } + + /// Set the gas limit for the hook call. + pub fn set_gas_limit(&mut self, gas_limit: u64) -> &mut Self { + self.gas_limit = Some(gas_limit); + self + } +} + +impl ToProtobuf for EvmHookCall { + type Protobuf = services::EvmHookCall; + + fn to_protobuf(&self) -> Self::Protobuf { + services::EvmHookCall { + data: self.call_data.clone().unwrap_or_default(), + gas_limit: self.gas_limit.unwrap_or(0), + } + } +} + +impl FromProtobuf for EvmHookCall { + fn from_protobuf(pb: services::EvmHookCall) -> crate::Result { + Ok(Self { + call_data: if pb.data.is_empty() { None } else { Some(pb.data) }, + gas_limit: Some(pb.gas_limit), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_evm_hook_call_creation() { + let call_data = vec![1, 2, 3, 4, 5]; + let hook_call = EvmHookCall::new(Some(call_data.clone())); + + assert_eq!(hook_call.call_data, Some(call_data)); + } + + #[test] + fn test_evm_hook_call_setters() { + let mut hook_call = EvmHookCall::new(None); + let call_data = vec![6, 7, 8, 9, 10]; + + hook_call.set_call_data(call_data.clone()); + assert_eq!(hook_call.call_data, Some(call_data)); + } + + #[test] + fn test_evm_hook_call_protobuf_roundtrip() { + let call_data = vec![11, 12, 13, 14, 15]; + let original = EvmHookCall::new(Some(call_data)); + + let protobuf = original.to_protobuf(); + let reconstructed = EvmHookCall::from_protobuf(protobuf).unwrap(); + + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/evm_hook_spec.rs b/src/hooks/evm_hook_spec.rs new file mode 100644 index 000000000..da36956d7 --- /dev/null +++ b/src/hooks/evm_hook_spec.rs @@ -0,0 +1,125 @@ +use hedera_proto::services; + +use crate::contract::ContractId; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// Shared specifications for an EVM hook. May be used for any extension point. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvmHookSpec { + /// The id of a contract that implements the extension point API with EVM bytecode. + pub contract_id: Option, +} + +impl EvmHookSpec { + /// Create a new `EvmHookSpec`. + pub fn new(contract_id: Option) -> Self { + Self { contract_id } + } +} + +impl ToProtobuf for EvmHookSpec { + type Protobuf = services::EvmHookSpec; + + fn to_protobuf(&self) -> Self::Protobuf { + services::EvmHookSpec { + bytecode_source: self + .contract_id + .as_ref() + .map(|id| services::evm_hook_spec::BytecodeSource::ContractId(id.to_protobuf())), + } + } +} + +impl FromProtobuf for EvmHookSpec { + #[allow(unreachable_patterns)] + fn from_protobuf(pb: services::EvmHookSpec) -> crate::Result { + let contract_id = match pb.bytecode_source { + Some(services::evm_hook_spec::BytecodeSource::ContractId(id)) => { + Some(ContractId::from_protobuf(id)?) + } + // For future unsupported bytecode sources. + Some(_) => { + return Err(crate::Error::from_protobuf("unsupported EvmHookSpec.bytecode_source")); + } + None => None, + }; + Ok(Self { contract_id }) + } +} + +#[cfg(test)] +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::ContractId; + + #[test] + fn new_with_contract_id_sets_field() { + let cid = ContractId::new(0, 0, 123); + let spec = EvmHookSpec::new(Some(cid)); + assert_eq!(spec.contract_id, Some(cid)); + } + + #[test] + fn new_without_contract_id_sets_none() { + let spec = EvmHookSpec::new(None); + assert!(spec.contract_id.is_none()); + } + + #[test] + fn to_protobuf_with_contract_id_sets_bytecode_source() { + let cid = ContractId::new(0, 0, 321); + let spec = EvmHookSpec::new(Some(cid)); + let pb = spec.to_protobuf(); + + let got = match pb.bytecode_source { + Some(hedera_proto::services::evm_hook_spec::BytecodeSource::ContractId(id)) => { + Some(ContractId::from_protobuf(id).unwrap()) + } + None => None, + }; + + assert_eq!(got, Some(cid)); + } + + #[test] + fn to_protobuf_without_contract_id_sets_none() { + let spec = EvmHookSpec::new(None); + let pb = spec.to_protobuf(); + assert!(pb.bytecode_source.is_none()); + } + + #[test] + fn from_protobuf_with_contract_id_parses() { + let cid = ContractId::new(0, 0, 555); + let pb = hedera_proto::services::EvmHookSpec { + bytecode_source: Some( + hedera_proto::services::evm_hook_spec::BytecodeSource::ContractId( + cid.to_protobuf(), + ), + ), + }; + + let spec = EvmHookSpec::from_protobuf(pb).unwrap(); + assert_eq!(spec.contract_id, Some(cid)); + } + + #[test] + fn from_protobuf_without_contract_id_parses_none() { + let pb = hedera_proto::services::EvmHookSpec { bytecode_source: None }; + let spec = EvmHookSpec::from_protobuf(pb).unwrap(); + assert!(spec.contract_id.is_none()); + } + + #[test] + fn protobuf_roundtrip() { + let cid = ContractId::new(0, 0, 999); + let original = EvmHookSpec::new(Some(cid)); + let pb = original.to_protobuf(); + let reconstructed = EvmHookSpec::from_protobuf(pb).unwrap(); + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/fungible_hook_call.rs b/src/hooks/fungible_hook_call.rs new file mode 100644 index 000000000..395de3da4 --- /dev/null +++ b/src/hooks/fungible_hook_call.rs @@ -0,0 +1,73 @@ +use hedera_proto::services; + +use crate::hooks::{ + EvmHookCall, + FungibleHookType, + HookCall, +}; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// A typed hook call for fungible (HBAR and FT) transfers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FungibleHookCall { + /// The underlying hook call data. + pub hook_call: HookCall, + /// The type of fungible hook. + pub hook_type: FungibleHookType, +} + +impl FungibleHookCall { + /// Create a new `FungibleHookCall`. + pub fn new(hook_call: HookCall, hook_type: FungibleHookType) -> Self { + Self { hook_call, hook_type } + } + + /// Internal method to create from protobuf with a known type. + pub(crate) fn from_protobuf_with_type( + pb: services::HookCall, + hook_type: FungibleHookType, + ) -> crate::Result { + Ok(Self { hook_call: HookCall::from_protobuf(pb)?, hook_type }) + } +} + +impl ToProtobuf for FungibleHookCall { + type Protobuf = services::HookCall; + + fn to_protobuf(&self) -> Self::Protobuf { + self.hook_call.to_protobuf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fungible_hook_call_creation() { + let hook_id = 123; + let hook_type = FungibleHookType::PreTxAllowanceHook; + let mut hook_call_obj = HookCall::new(None, None); + hook_call_obj.set_hook_id(hook_id); + let hook_call = FungibleHookCall::new(hook_call_obj, hook_type); + + assert_eq!(hook_call.hook_call.hook_id, Some(hook_id)); + assert_eq!(hook_call.hook_type, hook_type); + } + + #[test] + fn test_fungible_hook_call_with_call() { + let call_data = vec![1, 2, 3, 4, 5]; + let evm_call = EvmHookCall::new(Some(call_data)); + let hook_type = FungibleHookType::PrePostTxAllowanceHook; + let mut hook_call_obj = HookCall::new(None, None); + hook_call_obj.set_call(evm_call.clone()); + let hook_call = FungibleHookCall::new(hook_call_obj, hook_type); + + assert_eq!(hook_call.hook_call.call, Some(evm_call)); + assert_eq!(hook_call.hook_type, hook_type); + } +} diff --git a/src/hooks/fungible_hook_type.rs b/src/hooks/fungible_hook_type.rs new file mode 100644 index 000000000..609415300 --- /dev/null +++ b/src/hooks/fungible_hook_type.rs @@ -0,0 +1,28 @@ +/// Types of fungible (HBAR and FT) hooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FungibleHookType { + /// A single call made before attempting the transfer. + PreTxAllowanceHook = 0, + /// Two calls - first before attempting the transfer (allowPre), and second after + /// attempting the transfer (allowPost). + PrePostTxAllowanceHook = 1, +} + +impl FungibleHookType { + /// Returns the numeric value of the hook type. + pub fn value(&self) -> u8 { + *self as u8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fungible_hook_type_values() { + assert_eq!(FungibleHookType::PreTxAllowanceHook.value(), 0); + assert_eq!(FungibleHookType::PrePostTxAllowanceHook.value(), 1); + } +} diff --git a/src/hooks/hook_call.rs b/src/hooks/hook_call.rs new file mode 100644 index 000000000..83eaf30dd --- /dev/null +++ b/src/hooks/hook_call.rs @@ -0,0 +1,154 @@ +use hedera_proto::services; + +use crate::hooks::{ + EvmHookCall, + HookId, +}; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// A hook call containing a hook ID and call data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookCall { + /// The full hook ID (entity_id + numeric id). + pub full_hook_id: Option, + /// The numeric ID of the hook (when entity is implied). + pub hook_id: Option, + /// The call data for the hook. + pub call: Option, +} + +impl HookCall { + /// Create a new `HookCall`. + pub fn new(hook_id: Option, call: Option) -> Self { + Self { full_hook_id: None, hook_id, call } + } + + /// Set the full hook ID (clears hook_id if set). + pub fn set_full_hook_id(&mut self, full_hook_id: HookId) -> &mut Self { + self.full_hook_id = Some(full_hook_id); + self.hook_id = None; // Clear hook_id since they're mutually exclusive + self + } + + /// Set the hook ID (clears full_hook_id if set). + pub fn set_hook_id(&mut self, hook_id: i64) -> &mut Self { + self.hook_id = Some(hook_id); + self.full_hook_id = None; // Clear full_hook_id since they're mutually exclusive + self + } + + /// Set the call data. + pub fn set_call(&mut self, call: EvmHookCall) -> &mut Self { + self.call = Some(call); + self + } +} + +impl ToProtobuf for HookCall { + type Protobuf = services::HookCall; + + fn to_protobuf(&self) -> Self::Protobuf { + let id = if let Some(full_hook_id) = &self.full_hook_id { + Some(services::hook_call::Id::FullHookId(full_hook_id.to_protobuf())) + } else if let Some(hook_id) = self.hook_id { + Some(services::hook_call::Id::HookId(hook_id)) + } else { + None + }; + + let call_spec = self + .call + .as_ref() + .map(|call| services::hook_call::CallSpec::EvmHookCall(call.to_protobuf())); + + services::HookCall { id, call_spec } + } +} + +impl FromProtobuf for HookCall { + fn from_protobuf(pb: services::HookCall) -> crate::Result { + let (full_hook_id, hook_id) = match pb.id { + Some(services::hook_call::Id::FullHookId(id)) => { + (Some(HookId::from_protobuf(id)?), None) + } + Some(services::hook_call::Id::HookId(id)) => (None, Some(id)), + None => (None, None), + }; + + let call = match pb.call_spec { + Some(services::hook_call::CallSpec::EvmHookCall(call)) => { + Some(EvmHookCall::from_protobuf(call)?) + } + None => None, + }; + + Ok(Self { full_hook_id, hook_id, call }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_call_creation() { + let hook_id = 123; + let call_data = vec![1, 2, 3, 4, 5]; + let evm_call = EvmHookCall::new(Some(call_data)); + + let hook_call = HookCall::new(Some(hook_id), Some(evm_call.clone())); + + assert_eq!(hook_call.hook_id, Some(hook_id)); + assert_eq!(hook_call.call, Some(evm_call)); + } + + #[test] + fn test_hook_call_with_hook_id() { + let hook_id = 456; + let mut hook_call = HookCall::new(None, None); + hook_call.set_hook_id(hook_id); + + assert_eq!(hook_call.hook_id, Some(hook_id)); + assert_eq!(hook_call.call, None); + } + + #[test] + fn test_hook_call_with_call() { + let call_data = vec![6, 7, 8, 9, 10]; + let evm_call = EvmHookCall::new(Some(call_data.clone())); + let mut hook_call = HookCall::new(None, None); + hook_call.set_call(evm_call.clone()); + + assert_eq!(hook_call.hook_id, None); + assert_eq!(hook_call.call, Some(evm_call)); + } + + #[test] + fn test_hook_call_setters() { + let mut hook_call = HookCall::new(None, None); + let hook_id = 789; + let call_data = vec![11, 12, 13, 14, 15]; + let evm_call = EvmHookCall::new(Some(call_data)); + + hook_call.set_hook_id(hook_id).set_call(evm_call.clone()); + + assert_eq!(hook_call.hook_id, Some(hook_id)); + assert_eq!(hook_call.call, Some(evm_call)); + } + + #[test] + fn test_hook_call_protobuf_roundtrip() { + let hook_id = 999; + let call_data = vec![16, 17, 18, 19, 20]; + let evm_call = EvmHookCall::new(Some(call_data)); + let original = HookCall::new(Some(hook_id), Some(evm_call)); + + let protobuf = original.to_protobuf(); + let reconstructed = HookCall::from_protobuf(protobuf).unwrap(); + + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/hook_creation_details.rs b/src/hooks/hook_creation_details.rs new file mode 100644 index 000000000..e3fbac6a0 --- /dev/null +++ b/src/hooks/hook_creation_details.rs @@ -0,0 +1,70 @@ +use hedera_proto::services; + +use crate::hooks::{ + HookExtensionPoint, + LambdaEvmHook, +}; +use crate::key::Key; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// Details for creating a hook. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookCreationDetails { + /// The extension point for the hook. + pub extension_point: HookExtensionPoint, + /// The ID to create the hook at. + pub hook_id: i64, + /// The hook implementation (currently only Lambda EVM hooks). + pub lambda_evm_hook: Option, + /// Admin key for the hook (if any). + pub admin_key: Option, +} + +impl HookCreationDetails { + /// Create a new `HookCreationDetails`. + pub fn new( + extension_point: HookExtensionPoint, + hook_id: i64, + lambda_evm_hook: Option, + ) -> Self { + Self { extension_point, hook_id, lambda_evm_hook, admin_key: None } + } +} + +impl ToProtobuf for HookCreationDetails { + type Protobuf = services::HookCreationDetails; + + fn to_protobuf(&self) -> Self::Protobuf { + let hook = self + .lambda_evm_hook + .as_ref() + .map(|h| services::hook_creation_details::Hook::LambdaEvmHook(h.to_protobuf())); + + services::HookCreationDetails { + extension_point: self.extension_point as i32, + hook_id: self.hook_id, + hook, + admin_key: self.admin_key.as_ref().map(|k| k.to_protobuf()), + } + } +} + +impl FromProtobuf for HookCreationDetails { + fn from_protobuf(pb: services::HookCreationDetails) -> crate::Result { + let extension_point = HookExtensionPoint::try_from(pb.extension_point)?; + + let lambda_evm_hook = match pb.hook { + Some(services::hook_creation_details::Hook::LambdaEvmHook(hook)) => { + Some(LambdaEvmHook::from_protobuf(hook)?) + } + None => None, + }; + + let admin_key = pb.admin_key.map(Key::from_protobuf).transpose()?; + + Ok(Self { extension_point, hook_id: pb.hook_id, lambda_evm_hook, admin_key }) + } +} diff --git a/src/hooks/hook_entity_id.rs b/src/hooks/hook_entity_id.rs new file mode 100644 index 000000000..edb504c1b --- /dev/null +++ b/src/hooks/hook_entity_id.rs @@ -0,0 +1,103 @@ +use hedera_proto::services; + +use crate::account::AccountId; +use crate::contract::ContractId; +use crate::ledger_id::RefLedgerId; +use crate::{ + Error, + FromProtobuf, + ToProtobuf, + ValidateChecksums, +}; + +/// A hook entity identifier that can contain an account ID or contract ID. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HookEntityId { + pub account_id: Option, + pub contract_id: Option, +} + +impl HookEntityId { + pub fn new(account_id: Option) -> Self { + Self { account_id, contract_id: None } + } + + pub fn empty() -> Self { + Self { account_id: None, contract_id: None } + } +} + +impl ToProtobuf for HookEntityId { + type Protobuf = services::HookEntityId; + + fn to_protobuf(&self) -> Self::Protobuf { + let entity_id = if let Some(account_id) = &self.account_id { + Some(services::hook_entity_id::EntityId::AccountId(account_id.to_protobuf())) + } else if let Some(contract_id) = &self.contract_id { + Some(services::hook_entity_id::EntityId::ContractId(contract_id.to_protobuf())) + } else { + None + }; + + services::HookEntityId { entity_id } + } +} + +impl FromProtobuf for HookEntityId { + fn from_protobuf(pb: services::HookEntityId) -> crate::Result { + let (account_id, contract_id) = match pb.entity_id { + Some(services::hook_entity_id::EntityId::AccountId(id)) => { + (Some(AccountId::from_protobuf(id)?), None) + } + Some(services::hook_entity_id::EntityId::ContractId(id)) => { + (None, Some(ContractId::from_protobuf(id)?)) + } + None => (None, None), + }; + + Ok(Self { account_id, contract_id }) + } +} + +impl ValidateChecksums for HookEntityId { + fn validate_checksums(&self, ledger_id: &RefLedgerId) -> Result<(), Error> { + if let Some(account_id) = &self.account_id { + account_id.validate_checksums(ledger_id)?; + } + if let Some(contract_id) = &self.contract_id { + contract_id.validate_checksums(ledger_id)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_entity_id_with_account_id() { + let account_id = AccountId::new(0, 0, 123); + let hook_entity_id = HookEntityId::new(Some(account_id)); + + assert_eq!(hook_entity_id.account_id, Some(account_id)); + } + + #[test] + fn test_hook_entity_id_empty() { + let hook_entity_id = HookEntityId::empty(); + + assert_eq!(hook_entity_id.account_id, None); + } + + #[test] + fn test_hook_entity_id_protobuf_roundtrip() { + let account_id = AccountId::new(0, 0, 456); + let original = HookEntityId::new(Some(account_id)); + + let protobuf = original.to_protobuf(); + let reconstructed = HookEntityId::from_protobuf(protobuf).unwrap(); + + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/hook_extension_point.rs b/src/hooks/hook_extension_point.rs new file mode 100644 index 000000000..2a4767fab --- /dev/null +++ b/src/hooks/hook_extension_point.rs @@ -0,0 +1,82 @@ +/// Hook extension points that can be used to register hooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum HookExtensionPoint { + /// Account allowance hook extension point. + AccountAllowanceHook = 0, +} + +impl HookExtensionPoint { + /// Get the numeric value of the extension point. + pub const fn value(self) -> u32 { + self as u32 + } + + /// Create a `HookExtensionPoint` from a numeric value. + /// + /// # Errors + /// Returns `None` if the value is not a valid extension point. + pub const fn from_value(value: u32) -> Option { + match value { + 0 => Some(Self::AccountAllowanceHook), + _ => None, + } + } +} + +impl From for u32 { + fn from(point: HookExtensionPoint) -> Self { + point.value() + } +} + +impl TryFrom for HookExtensionPoint { + type Error = (); + + fn try_from(value: u32) -> Result { + Self::from_value(value).ok_or(()) + } +} + +impl TryFrom for HookExtensionPoint { + type Error = crate::Error; + + fn try_from(value: i32) -> Result { + if value < 0 { + return Err(crate::Error::basic_parse("HookExtensionPoint value cannot be negative")); + } + Self::from_value(value as u32) + .ok_or_else(|| crate::Error::basic_parse("Invalid HookExtensionPoint value")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_extension_point_values() { + assert_eq!(HookExtensionPoint::AccountAllowanceHook.value(), 0); + } + + #[test] + fn test_hook_extension_point_from_value() { + assert_eq!( + HookExtensionPoint::from_value(0), + Some(HookExtensionPoint::AccountAllowanceHook) + ); + assert_eq!(HookExtensionPoint::from_value(1), None); + } + + #[test] + fn test_hook_extension_point_conversions() { + let point = HookExtensionPoint::AccountAllowanceHook; + let value: u32 = point.into(); + assert_eq!(value, 0); + + let point_from_value = HookExtensionPoint::try_from(0).unwrap(); + assert_eq!(point_from_value, HookExtensionPoint::AccountAllowanceHook); + + assert!(HookExtensionPoint::try_from(999).is_err()); + } +} diff --git a/src/hooks/hook_id.rs b/src/hooks/hook_id.rs new file mode 100644 index 000000000..971fc5f1d --- /dev/null +++ b/src/hooks/hook_id.rs @@ -0,0 +1,84 @@ +use hedera_proto::services; + +use crate::hooks::HookEntityId; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// A hook identifier containing an entity ID and hook ID. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HookId { + /// The entity ID associated with this hook. + pub entity_id: Option, + /// The hook ID number. + pub hook_id: i64, +} + +impl HookId { + /// Create a new `HookId`. + pub fn new(entity_id: Option, hook_id: i64) -> Self { + Self { entity_id, hook_id } + } +} + +impl ToProtobuf for HookId { + type Protobuf = services::HookId; + + fn to_protobuf(&self) -> Self::Protobuf { + services::HookId { + entity_id: self.entity_id.as_ref().map(|id| id.to_protobuf()), + hook_id: self.hook_id, + } + } +} + +impl FromProtobuf for HookId { + fn from_protobuf(pb: services::HookId) -> crate::Result { + let entity_id = pb.entity_id.map(HookEntityId::from_protobuf).transpose()?; + + Ok(Self { entity_id, hook_id: pb.hook_id }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::account::AccountId; + + #[test] + fn test_hook_id_creation() { + let entity_id = HookEntityId::new(Some(AccountId::new(0, 0, 123))); + let hook_id = HookId::new(Some(entity_id.clone()), 456); + + assert_eq!(hook_id.entity_id, Some(entity_id)); + assert_eq!(hook_id.hook_id, 456); + } + + #[test] + fn test_hook_id_with_entity_id_only() { + let entity_id = HookEntityId::new(Some(AccountId::new(0, 0, 789))); + let hook_id = HookId::new(Some(entity_id.clone()), 123); + + assert_eq!(hook_id.entity_id, Some(entity_id)); + assert_eq!(hook_id.hook_id, 123); + } + + #[test] + fn test_hook_id_with_hook_id_only() { + let hook_id = HookId::new(None, 999); + + assert_eq!(hook_id.entity_id, None); + assert_eq!(hook_id.hook_id, 999); + } + + #[test] + fn test_hook_id_protobuf_roundtrip() { + let entity_id = HookEntityId::new(Some(AccountId::new(0, 0, 111))); + let original = HookId::new(Some(entity_id), 222); + let protobuf = original.to_protobuf(); + let reconstructed = HookId::from_protobuf(protobuf).unwrap(); + + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/hook_type.rs b/src/hooks/hook_type.rs new file mode 100644 index 000000000..6b7ee3be5 --- /dev/null +++ b/src/hooks/hook_type.rs @@ -0,0 +1,80 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum HookType { + PreHook = 0, + PrePostHook = 1, + PreHookSender = 2, + PrePostHookSender = 3, + PreHookReceiver = 4, + PrePostHookReceiver = 5, +} + +impl HookType { + pub const fn value(self) -> u32 { + self as u32 + } + + pub const fn from_value(value: u32) -> Option { + match value { + 0 => Some(Self::PreHook), + 1 => Some(Self::PrePostHook), + 2 => Some(Self::PreHookSender), + 3 => Some(Self::PrePostHookSender), + 4 => Some(Self::PreHookReceiver), + 5 => Some(Self::PrePostHookReceiver), + _ => None, + } + } +} + +impl From for u32 { + fn from(hook_type: HookType) -> Self { + hook_type.value() + } +} + +impl TryFrom for HookType { + type Error = (); + + fn try_from(value: u32) -> Result { + Self::from_value(value).ok_or(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_type_values() { + assert_eq!(HookType::PreHook.value(), 0); + assert_eq!(HookType::PrePostHook.value(), 1); + assert_eq!(HookType::PreHookSender.value(), 2); + assert_eq!(HookType::PrePostHookSender.value(), 3); + assert_eq!(HookType::PreHookReceiver.value(), 4); + assert_eq!(HookType::PrePostHookReceiver.value(), 5); + } + + #[test] + fn test_hook_type_from_value() { + assert_eq!(HookType::from_value(0), Some(HookType::PreHook)); + assert_eq!(HookType::from_value(1), Some(HookType::PrePostHook)); + assert_eq!(HookType::from_value(2), Some(HookType::PreHookSender)); + assert_eq!(HookType::from_value(3), Some(HookType::PrePostHookSender)); + assert_eq!(HookType::from_value(4), Some(HookType::PreHookReceiver)); + assert_eq!(HookType::from_value(5), Some(HookType::PrePostHookReceiver)); + assert_eq!(HookType::from_value(6), None); + } + + #[test] + fn test_hook_type_conversions() { + let hook_type = HookType::PrePostHook; + let value: u32 = hook_type.into(); + assert_eq!(value, 1); + + let hook_type_from_value = HookType::try_from(1).unwrap(); + assert_eq!(hook_type_from_value, HookType::PrePostHook); + + assert!(HookType::try_from(999).is_err()); + } +} diff --git a/src/hooks/lambda_evm_hook.rs b/src/hooks/lambda_evm_hook.rs new file mode 100644 index 000000000..476122f40 --- /dev/null +++ b/src/hooks/lambda_evm_hook.rs @@ -0,0 +1,130 @@ +use hedera_proto::services; + +use crate::hooks::{ + EvmHookSpec, + LambdaStorageUpdate, +}; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LambdaEvmHook { + pub spec: EvmHookSpec, + pub storage_updates: Vec, +} + +impl LambdaEvmHook { + pub fn new(spec: EvmHookSpec, storage_updates: Vec) -> Self { + Self { spec, storage_updates } + } + + pub fn set_storage_updates(&mut self, storage_updates: Vec) -> &mut Self { + self.storage_updates = storage_updates; + self + } + + pub fn add_storage_update(&mut self, storage_update: LambdaStorageUpdate) -> &mut Self { + self.storage_updates.push(storage_update); + self + } +} + +impl ToProtobuf for LambdaEvmHook { + type Protobuf = services::LambdaEvmHook; + + fn to_protobuf(&self) -> Self::Protobuf { + services::LambdaEvmHook { + spec: Some(self.spec.to_protobuf()), + storage_updates: self + .storage_updates + .iter() + .map(|update| update.to_protobuf()) + .collect(), + } + } +} + +impl FromProtobuf for LambdaEvmHook { + fn from_protobuf(pb: services::LambdaEvmHook) -> crate::Result { + let spec = pb + .spec + .map(EvmHookSpec::from_protobuf) + .transpose()? + .unwrap_or_else(|| EvmHookSpec::new(None)); + + let storage_updates = pb + .storage_updates + .into_iter() + .map(LambdaStorageUpdate::from_protobuf) + .collect::, _>>()?; + + Ok(Self { spec, storage_updates }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::ContractId; + use crate::hooks::LambdaStorageSlot; + + #[test] + fn test_lambda_evm_hook_creation() { + let contract_id = ContractId::new(0, 0, 123); + let spec = EvmHookSpec::new(Some(contract_id)); + let storage_slot = LambdaStorageSlot::new(vec![1, 2, 3], vec![4, 5, 6]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + let storage_updates = vec![storage_update]; + + let hook = LambdaEvmHook::new(spec.clone(), storage_updates.clone()); + + assert_eq!(hook.spec, spec); + assert_eq!(hook.storage_updates, storage_updates); + } + + #[test] + fn test_lambda_evm_hook_with_spec_only() { + let contract_id = ContractId::new(0, 0, 456); + let spec = EvmHookSpec::new(Some(contract_id)); + let hook = LambdaEvmHook::new(spec.clone(), vec![]); + + assert_eq!(hook.spec, spec); + assert_eq!(hook.storage_updates.len(), 0); + } + + #[test] + fn test_lambda_evm_hook_setters() { + let contract_id = ContractId::new(0, 0, 789); + let spec = EvmHookSpec::new(Some(contract_id)); + let mut hook = LambdaEvmHook::new(spec, vec![]); + + let storage_slot = LambdaStorageSlot::new(vec![1, 2, 3], vec![4, 5, 6]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + let storage_updates = vec![storage_update.clone()]; + + hook.set_storage_updates(storage_updates.clone()); + assert_eq!(hook.storage_updates, storage_updates); + + let another_slot = LambdaStorageSlot::new(vec![7, 8, 9], vec![10, 11, 12]); + let another_update = LambdaStorageUpdate::StorageSlot(another_slot); + hook.add_storage_update(another_update); + + assert_eq!(hook.storage_updates.len(), 2); + } + + #[test] + fn test_lambda_evm_hook_protobuf_roundtrip() { + let contract_id = ContractId::new(0, 0, 111); + let spec = EvmHookSpec::new(Some(contract_id)); + let storage_slot = LambdaStorageSlot::new(vec![1, 2, 3], vec![4, 5, 6]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + let original = LambdaEvmHook::new(spec, vec![storage_update]); + + let protobuf = original.to_protobuf(); + let reconstructed = LambdaEvmHook::from_protobuf(protobuf).unwrap(); + + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/lambda_storage_slot.rs b/src/hooks/lambda_storage_slot.rs new file mode 100644 index 000000000..42457c25b --- /dev/null +++ b/src/hooks/lambda_storage_slot.rs @@ -0,0 +1,57 @@ +use hedera_proto::services; + +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// A slot in the storage of a lambda EVM hook. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LambdaStorageSlot { + /// The key of the slot. + pub key: Vec, + + /// The value of the slot. + pub value: Vec, +} + +impl LambdaStorageSlot { + /// Create a new `LambdaStorageSlot`. + pub fn new(key: Vec, value: Vec) -> Self { + Self { key, value } + } + + /// Get the key. + pub fn get_key(&self) -> &[u8] { + &self.key + } + + /// Get the value. + pub fn get_value(&self) -> &[u8] { + &self.value + } + + /// Set the value. + pub fn set_value(&mut self, value: Vec) { + self.value = value; + } + + /// Set the key. + pub fn set_key(&mut self, key: Vec) { + self.key = key; + } +} + +impl ToProtobuf for LambdaStorageSlot { + type Protobuf = services::LambdaStorageSlot; + + fn to_protobuf(&self) -> Self::Protobuf { + services::LambdaStorageSlot { key: self.key.clone(), value: self.value.clone() } + } +} + +impl FromProtobuf for LambdaStorageSlot { + fn from_protobuf(pb: services::LambdaStorageSlot) -> crate::Result { + Ok(Self { key: pb.key, value: pb.value }) + } +} diff --git a/src/hooks/lambda_storage_update.rs b/src/hooks/lambda_storage_update.rs new file mode 100644 index 000000000..54e305d29 --- /dev/null +++ b/src/hooks/lambda_storage_update.rs @@ -0,0 +1,202 @@ +use hedera_proto::services; + +use crate::hooks::LambdaStorageSlot; +use crate::{ + FromProtobuf, + ToProtobuf, +}; + +/// A lambda storage update containing either a storage slot or mapping entries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LambdaStorageUpdate { + StorageSlot(LambdaStorageSlot), + MappingEntries(LambdaMappingEntries), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LambdaMappingEntries { + pub mapping_slot: Vec, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LambdaMappingEntry { + pub key: Option>, + pub value: Option>, + pub preimage: Option>, +} + +impl LambdaStorageUpdate {} + +impl LambdaMappingEntries { + pub fn new(mapping_slot: Vec, entries: Vec) -> Self { + Self { mapping_slot, entries } + } +} + +impl LambdaMappingEntry { + pub fn new(key: Option>, value: Option>) -> Self { + Self { key, value, preimage: None } + } + + pub fn set_key(&mut self, key: Vec) -> &mut Self { + self.key = Some(key); + self.preimage = None; // Clear preimage since they're mutually exclusive + self + } + + pub fn set_value(&mut self, value: Vec) -> &mut Self { + self.value = Some(value); + self + } + + pub fn set_preimage(&mut self, preimage: Vec) -> &mut Self { + self.preimage = Some(preimage); + self.key = None; // Clear key since they're mutually exclusive + self + } +} + +impl ToProtobuf for LambdaStorageUpdate { + type Protobuf = services::LambdaStorageUpdate; + + fn to_protobuf(&self) -> Self::Protobuf { + match self { + Self::StorageSlot(slot) => services::LambdaStorageUpdate { + update: Some(services::lambda_storage_update::Update::StorageSlot( + slot.to_protobuf(), + )), + }, + Self::MappingEntries(entries) => services::LambdaStorageUpdate { + update: Some(services::lambda_storage_update::Update::MappingEntries( + entries.to_protobuf(), + )), + }, + } + } +} + +impl FromProtobuf for LambdaStorageUpdate { + fn from_protobuf(pb: services::LambdaStorageUpdate) -> crate::Result { + match pb.update { + Some(services::lambda_storage_update::Update::StorageSlot(slot)) => { + Ok(Self::StorageSlot(LambdaStorageSlot::from_protobuf(slot)?)) + } + Some(services::lambda_storage_update::Update::MappingEntries(entries)) => { + Ok(Self::MappingEntries(LambdaMappingEntries::from_protobuf(entries)?)) + } + None => Err(crate::Error::basic_parse( + "LambdaStorageUpdate must have either storage_slot or mapping_entries", + )), + } + } +} + +impl ToProtobuf for LambdaMappingEntries { + type Protobuf = services::LambdaMappingEntries; + + fn to_protobuf(&self) -> Self::Protobuf { + services::LambdaMappingEntries { + mapping_slot: self.mapping_slot.clone(), + entries: self.entries.iter().map(|entry| entry.to_protobuf()).collect(), + } + } +} + +impl FromProtobuf for LambdaMappingEntries { + fn from_protobuf(pb: services::LambdaMappingEntries) -> crate::Result { + let entries = pb + .entries + .into_iter() + .map(LambdaMappingEntry::from_protobuf) + .collect::, _>>()?; + + Ok(Self { mapping_slot: pb.mapping_slot, entries }) + } +} + +impl ToProtobuf for LambdaMappingEntry { + type Protobuf = services::LambdaMappingEntry; + + fn to_protobuf(&self) -> Self::Protobuf { + let entry_key = if let Some(key) = &self.key { + Some(services::lambda_mapping_entry::EntryKey::Key(key.clone())) + } else if let Some(preimage) = &self.preimage { + Some(services::lambda_mapping_entry::EntryKey::Preimage(preimage.clone())) + } else { + None + }; + + services::LambdaMappingEntry { entry_key, value: self.value.clone().unwrap_or_default() } + } +} + +impl FromProtobuf for LambdaMappingEntry { + fn from_protobuf(pb: services::LambdaMappingEntry) -> crate::Result { + let (key, preimage) = match pb.entry_key { + Some(services::lambda_mapping_entry::EntryKey::Key(k)) => (Some(k), None), + Some(services::lambda_mapping_entry::EntryKey::Preimage(p)) => (None, Some(p)), + None => (None, None), + }; + + Ok(Self { key, value: if pb.value.is_empty() { None } else { Some(pb.value) }, preimage }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lambda_mapping_entry_creation() { + let entry = LambdaMappingEntry::new(Some(vec![1, 2, 3]), Some(vec![4, 5, 6])); + assert_eq!(entry.key, Some(vec![1, 2, 3])); + assert_eq!(entry.value, Some(vec![4, 5, 6])); + assert_eq!(entry.preimage, None); + } + + #[test] + fn test_lambda_mapping_entry_with_preimage() { + let mut entry = LambdaMappingEntry::new(None, Some(vec![10, 11, 12])); + entry.set_preimage(vec![7, 8, 9]); + assert_eq!(entry.key, None); + assert_eq!(entry.preimage, Some(vec![7, 8, 9])); + assert_eq!(entry.value, Some(vec![10, 11, 12])); + } + + #[test] + fn test_lambda_mapping_entry_setters() { + let mut entry = LambdaMappingEntry::new(None, None); + entry.set_key(vec![7, 8, 9]).set_value(vec![10, 11, 12]); + + assert_eq!(entry.key, Some(vec![7, 8, 9])); + assert_eq!(entry.value, Some(vec![10, 11, 12])); + assert_eq!(entry.preimage, None); + } + + #[test] + fn test_lambda_mapping_entry_key_preimage_mutual_exclusion() { + let mut entry = LambdaMappingEntry::new(Some(vec![1, 2, 3]), None); + assert_eq!(entry.key, Some(vec![1, 2, 3])); + assert_eq!(entry.preimage, None); + + // Setting preimage should clear key + entry.set_preimage(vec![4, 5, 6]); + assert_eq!(entry.key, None); + assert_eq!(entry.preimage, Some(vec![4, 5, 6])); + + // Setting key should clear preimage + entry.set_key(vec![7, 8, 9]); + assert_eq!(entry.key, Some(vec![7, 8, 9])); + assert_eq!(entry.preimage, None); + } + + #[test] + fn test_lambda_mapping_entry_protobuf_roundtrip() { + let original = LambdaMappingEntry::new(Some(vec![1, 2, 3]), Some(vec![4, 5, 6])); + let protobuf = original.to_protobuf(); + let reconstructed = LambdaMappingEntry::from_protobuf(protobuf).unwrap(); + + assert_eq!(original, reconstructed); + } +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 000000000..090959287 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,31 @@ +pub mod evm_hook_call; +pub mod evm_hook_spec; +// pub mod fungible_hook_call; +// pub mod fungible_hook_type; +// pub mod hook_call; +pub mod hook_creation_details; +pub mod hook_entity_id; +pub mod hook_extension_point; +pub mod hook_id; +pub mod hook_type; +pub mod lambda_evm_hook; +// pub mod lambda_s_store_transaction; +pub mod lambda_storage_slot; +pub mod lambda_storage_update; +// pub mod nft_hook_call; +// pub mod nft_hook_type; + +pub use evm_hook_call::EvmHookCall; +pub use evm_hook_spec::EvmHookSpec; +//pub use fungible_hook_call::FungibleHookCall; +// pub use fungible_hook_type::FungibleHookType; +//pub use hook_call::HookCall; +pub use hook_creation_details::HookCreationDetails; +pub use hook_entity_id::HookEntityId; +pub use hook_extension_point::HookExtensionPoint; +pub use hook_id::HookId; +pub use lambda_evm_hook::LambdaEvmHook; +pub use lambda_storage_slot::LambdaStorageSlot; +pub use lambda_storage_update::LambdaStorageUpdate; +//pub use nft_hook_call::NftHookCall; +// pub use nft_hook_type::NftHookType; diff --git a/src/lib.rs b/src/lib.rs index 1e432fe96..bdf94b0dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,7 @@ mod execute; mod fee_schedules; mod file; mod hbar; +mod hooks; mod key; mod ledger_id; mod mirror_query; @@ -244,6 +245,22 @@ pub use hbar::{ Tinybar, }; pub use hedera_proto::services::ResponseCodeEnum as Status; +pub use hooks::{ + EvmHookCall, + EvmHookSpec, + // FungibleHookCall, + // FungibleHookType, + // HookCall, + HookCreationDetails, + HookEntityId, + HookExtensionPoint, + HookId, + LambdaEvmHook, + LambdaStorageSlot, + LambdaStorageUpdate, + // NftHookCall, + // NftHookType, +}; pub use key::{ Key, KeyList, diff --git a/src/query/payment_transaction.rs b/src/query/payment_transaction.rs index 68f80600b..784250f70 100644 --- a/src/query/payment_transaction.rs +++ b/src/query/payment_transaction.rs @@ -85,11 +85,13 @@ impl ToTransactionDataProtobuf for PaymentTransactionData { account_id: node_account_id.to_protobuf(), amount: amount.to_tinybars(), is_approval: false, + hook_call: None, }, services::AccountAmount { account_id: Some(transaction_id.account_id.to_protobuf()), amount: -(amount.to_tinybars()), is_approval: false, + hook_call: None, }, ], }), diff --git a/src/token/token_airdrop_transaction.rs b/src/token/token_airdrop_transaction.rs index 71824760a..f06030d40 100644 --- a/src/token/token_airdrop_transaction.rs +++ b/src/token/token_airdrop_transaction.rs @@ -523,21 +523,25 @@ mod tests { account_id: Some(AccountId::from_str("0.0.5008").unwrap().to_protobuf()), amount: 200, is_approval: false, + hook_call: None, }, AccountAmount { account_id: Some(AccountId::from_str("0.0.5009").unwrap().to_protobuf()), amount: -100, is_approval: false, + hook_call: None, }, AccountAmount { account_id: Some(AccountId::from_str("0.0.5010").unwrap().to_protobuf()), amount: 40, is_approval: false, + hook_call: None, }, AccountAmount { account_id: Some(AccountId::from_str("0.0.5011").unwrap().to_protobuf()), amount: 20, is_approval: false, + hook_call: None, }, ], nft_transfers: vec![NftTransfer { @@ -547,6 +551,8 @@ mod tests { ), serial_number: 1, is_approval: true, + receiver_allowance_hook_call: None, + sender_allowance_hook_call: None, }], expected_decimals: Some(3), }], diff --git a/src/transaction/any.rs b/src/transaction/any.rs index be10410f6..c1fae3e2b 100644 --- a/src/transaction/any.rs +++ b/src/transaction/any.rs @@ -683,6 +683,16 @@ impl FromProtobuf for AnyTransactionData { "unsupported transaction `AtomicBatchTransaction`", )) } + Data::LambdaSstore(_) => { + return Err(Error::from_protobuf( + "unsupported transaction `LambdaSstoreTransaction`", + )) + } + Data::HookDispatch(_) => { + return Err(Error::from_protobuf( + "unsupported transaction `HookDispatchTransaction`", + )) + } }; Ok(data) @@ -1067,6 +1077,12 @@ impl FromProtobuf> for ServicesTransaction Data::AtomicBatch(_) => { return Err(Error::from_protobuf("AtomicBatch transactions are not supported")) } + Data::LambdaSstore(_) => { + return Err(Error::from_protobuf("LambdaSstore transactions are not supported")) + } + Data::HookDispatch(_) => { + return Err(Error::from_protobuf("HookDispatch transactions are not supported")) + } }; for transaction in iter { 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/src/transaction_record.rs b/src/transaction_record.rs index 20d20c51e..420bc536a 100644 --- a/src/transaction_record.rs +++ b/src/transaction_record.rs @@ -274,6 +274,7 @@ impl ToProtobuf for TransactionRecord { account_id: Some(it.0.to_protobuf()), amount: *it.1, is_approval: false, + hook_call: None, }) .collect(), nft_transfers: Vec::new(), diff --git a/src/transfer.rs b/src/transfer.rs index ea18c0e40..ba4c01781 100644 --- a/src/transfer.rs +++ b/src/transfer.rs @@ -43,6 +43,7 @@ impl ToProtobuf for Transfer { account_id: Some(self.account_id.to_protobuf()), amount: self.amount.to_tinybars(), is_approval: false, + hook_call: None, } } } diff --git a/src/transfer_transaction.rs b/src/transfer_transaction.rs index 54bea4bbd..fc7a81587 100644 --- a/src/transfer_transaction.rs +++ b/src/transfer_transaction.rs @@ -325,6 +325,7 @@ impl ToProtobuf for Transfer { amount: self.amount, account_id: Some(self.account_id.to_protobuf()), is_approval: self.is_approval, + hook_call: None, } } } @@ -371,6 +372,8 @@ impl ToProtobuf for TokenNftTransfer { receiver_account_id: Some(self.receiver.to_protobuf()), serial_number: self.serial as i64, is_approval: self.is_approved, + receiver_allowance_hook_call: None, + sender_allowance_hook_call: None, } } } diff --git a/tests/e2e/account/account_create_with_hooks.rs b/tests/e2e/account/account_create_with_hooks.rs new file mode 100644 index 000000000..08afe575f --- /dev/null +++ b/tests/e2e/account/account_create_with_hooks.rs @@ -0,0 +1,234 @@ +use assert_matches::assert_matches; +use hedera::{ + AccountCreateTransaction, + ContractCreateTransaction, + ContractId, + EvmHookSpec, + Hbar, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, + LambdaStorageSlot, + LambdaStorageUpdate, + PrivateKey, + Status, +}; + +use crate::common::{ + setup_nonfree, + TestEnvironment, +}; + +const HOOK_BYTECODE: &str = "6080604052348015600e575f5ffd5b506103da8061001c5f395ff3fe60806040526004361061001d575f3560e01c80630b6c5c0414610021575b5f5ffd5b61003b6004803603810190610036919061021c565b610051565b60405161004891906102ed565b60405180910390f35b5f61016d73ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16146100c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100b990610386565b60405180910390fd5b60019050979650505050505050565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610102826100d9565b9050919050565b610112816100f8565b811461011c575f5ffd5b50565b5f8135905061012d81610109565b92915050565b5f819050919050565b61014581610133565b811461014f575f5ffd5b50565b5f813590506101608161013c565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261018757610186610166565b5b8235905067ffffffffffffffff8111156101a4576101a361016a565b5b6020830191508360018202830111156101c0576101bf61016e565b5b9250929050565b5f5f83601f8401126101dc576101db610166565b5b8235905067ffffffffffffffff8111156101f9576101f861016a565b5b6020830191508360018202830111156102155761021461016e565b5b9250929050565b5f5f5f5f5f5f5f60a0888a031215610237576102366100d1565b5b5f6102448a828b0161011f565b97505060206102558a828b01610152565b96505060406102668a828b01610152565b955050606088013567ffffffffffffffff811115610287576102866100d5565b5b6102938a828b01610172565b9450945050608088013567ffffffffffffffff8111156102b6576102b56100d5565b5b6102c28a828b016101c7565b925092505092959891949750929550565b5f8115159050919050565b6102e7816102d3565b82525050565b5f6020820190506103005f8301846102de565b92915050565b5f82825260208201905092915050565b7f436f6e74726163742063616e206f6e6c792062652063616c6c656420617320615f8201527f20686f6f6b000000000000000000000000000000000000000000000000000000602082015250565b5f610370602583610306565b915061037b82610316565b604082019050919050565b5f6020820190508181035f83015261039d81610364565b905091905056fea2646970667358221220a8c76458204f8bb9a86f59ec2f0ccb7cbe8ae4dcb65700c4b6ee91a39404083a64736f6c634300081e0033"; + +async fn create_hook_contract(client: &hedera::Client) -> anyhow::Result { + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .execute(client) + .await? + .get_receipt(client) + .await?; + + Ok(receipt.contract_id.unwrap()) +} + +#[tokio::test] +async fn can_create_account_with_basic_lambda_hook() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let receipt = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(receipt.status, Status::Success); + assert!(receipt.account_id.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn can_create_account_with_lambda_hook_and_storage() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + let storage_slot = + LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![0x05, 0x06, 0x07, 0x08]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![storage_update]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let receipt = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(receipt.status, Status::Success); + assert!(receipt.account_id.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn cannot_create_account_with_duplicate_hook_ids() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec.clone(), vec![]); + + let hook_details1 = HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 1, + Some(lambda_hook.clone()), + ); + + let hook_details2 = HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 1, // Same ID - should fail + Some(lambda_hook), + ); + + let result = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .add_hook(hook_details1) + .add_hook(hook_details2) + .freeze_with(&client)? + .sign(key) + .execute(&client) // Error happens HERE + .await; // Not at get_receipt + + assert!(result.is_err()); + if let Err(err) = result { + let err_str = err.to_string(); + assert!( + err_str.contains("HOOK_ID_REPEATED_IN_CREATION_DETAILS") + || err_str.contains("HookIdRepeatedInCreationDetails"), + "Expected HOOK_ID_REPEATED_IN_CREATION_DETAILS error, got: {}", + err_str + ); + } + + Ok(()) +} + +#[tokio::test] +async fn cannot_create_account_with_lambda_hook_without_contract_id() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let key = PrivateKey::generate_ed25519(); + + // Create hook without contract ID + let storage_slot = + LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![0x05, 0x06, 0x07, 0x08]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let spec = EvmHookSpec::new(None); // No contract ID + let lambda_hook = LambdaEvmHook::new(spec, vec![storage_update]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let result = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert!(result.is_err()); + if let Err(err) = result { + let err_str = err.to_string(); + assert!( + err_str.contains("INVALID_HOOK_CREATION_SPEC") + || err_str.contains("InvalidHookCreationSpec"), + "Expected INVALID_HOOK_CREATION_SPEC error, got: {}", + err_str + ); + } + + Ok(()) +} + +#[tokio::test] +async fn can_create_account_with_hook_admin_key() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let account_key = PrivateKey::generate_ed25519(); + let admin_key = PrivateKey::generate_ed25519(); + + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![]); + + let mut hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + hook_details.admin_key = Some(admin_key.public_key().into()); + + let receipt = AccountCreateTransaction::new() + .key(account_key.public_key()) + .initial_balance(Hbar::new(1)) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(receipt.status, Status::Success); + assert!(receipt.account_id.is_some()); + + Ok(()) +} diff --git a/tests/e2e/account/account_update_with_hooks.rs b/tests/e2e/account/account_update_with_hooks.rs new file mode 100644 index 000000000..7a5f79a74 --- /dev/null +++ b/tests/e2e/account/account_update_with_hooks.rs @@ -0,0 +1,278 @@ +use assert_matches::assert_matches; +use hedera::{ + AccountCreateTransaction, + AccountUpdateTransaction, + ContractCreateTransaction, + ContractId, + EvmHookSpec, + Hbar, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, + LambdaStorageSlot, + LambdaStorageUpdate, + PrivateKey, + Status, +}; + +use crate::common::{ + setup_nonfree, + TestEnvironment, +}; + +const HOOK_BYTECODE: &str = "6080604052348015600e575f5ffd5b506103da8061001c5f395ff3fe60806040526004361061001d575f3560e01c80630b6c5c0414610021575b5f5ffd5b61003b6004803603810190610036919061021c565b610051565b60405161004891906102ed565b60405180910390f35b5f61016d73ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16146100c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100b990610386565b60405180910390fd5b60019050979650505050505050565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610102826100d9565b9050919050565b610112816100f8565b811461011c575f5ffd5b50565b5f8135905061012d81610109565b92915050565b5f819050919050565b61014581610133565b811461014f575f5ffd5b50565b5f813590506101608161013c565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261018757610186610166565b5b8235905067ffffffffffffffff8111156101a4576101a361016a565b5b6020830191508360018202830111156101c0576101bf61016e565b5b9250929050565b5f5f83601f8401126101dc576101db610166565b5b8235905067ffffffffffffffff8111156101f9576101f861016a565b5b6020830191508360018202830111156102155761021461016e565b5b9250929050565b5f5f5f5f5f5f5f60a0888a031215610237576102366100d1565b5b5f6102448a828b0161011f565b97505060206102558a828b01610152565b96505060406102668a828b01610152565b955050606088013567ffffffffffffffff811115610287576102866100d5565b5b6102938a828b01610172565b9450945050608088013567ffffffffffffffff8111156102b6576102b56100d5565b5b6102c28a828b016101c7565b925092505092959891949750929550565b5f8115159050919050565b6102e7816102d3565b82525050565b5f6020820190506103005f8301846102de565b92915050565b5f82825260208201905092915050565b7f436f6e74726163742063616e206f6e6c792062652063616c6c656420617320615f8201527f20686f6f6b000000000000000000000000000000000000000000000000000000602082015250565b5f610370602583610306565b915061037b82610316565b604082019050919050565b5f6020820190508181035f83015261039d81610364565b905091905056fea2646970667358221220a8c76458204f8bb9a86f59ec2f0ccb7cbe8ae4dcb65700c4b6ee91a39404083a64736f6c634300081e0033"; + +async fn create_hook_contract(client: &hedera::Client) -> anyhow::Result { + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .execute(client) + .await? + .get_receipt(client) + .await?; + + Ok(receipt.contract_id.unwrap()) +} + +#[tokio::test] +async fn can_update_account_to_add_lambda_hook() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + // First create an account without hooks + let create_receipt = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let account_id = create_receipt.account_id.unwrap(); + + // Now update it to add a hook + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let update_receipt = AccountUpdateTransaction::new() + .account_id(account_id) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(update_receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn can_update_account_with_multiple_hooks() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + // Create account + let create_receipt = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let account_id = create_receipt.account_id.unwrap(); + + // Add multiple hooks + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook1 = LambdaEvmHook::new(spec.clone(), vec![]); + let lambda_hook2 = LambdaEvmHook::new(spec, vec![]); + + let hook_details1 = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook1)); + + let hook_details2 = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 2, Some(lambda_hook2)); + + let update_receipt = AccountUpdateTransaction::new() + .account_id(account_id) + .add_hook(hook_details1) + .add_hook(hook_details2) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(update_receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn can_update_account_hook_with_storage_updates() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + // Create account + let create_receipt = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let account_id = create_receipt.account_id.unwrap(); + + // Add hook with storage updates + let storage_slot = + LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![0xFF, 0xEE, 0xDD, 0xCC]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![storage_update]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let update_receipt = AccountUpdateTransaction::new() + .account_id(account_id) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(update_receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn cannot_update_account_with_duplicate_hook_ids() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let key = PrivateKey::generate_ed25519(); + + // Create account + let create_receipt = AccountCreateTransaction::new() + .key(key.public_key()) + .initial_balance(Hbar::new(1)) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let account_id = create_receipt.account_id.unwrap(); + + // Try to add hooks with duplicate IDs + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook1 = LambdaEvmHook::new(spec.clone(), vec![]); + let lambda_hook2 = LambdaEvmHook::new(spec, vec![]); + + let hook_details1 = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook1)); + + let hook_details2 = HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 1, // Same ID - should fail + Some(lambda_hook2), + ); + + let result = AccountUpdateTransaction::new() + .account_id(account_id) + .add_hook(hook_details1) + .add_hook(hook_details2) + .freeze_with(&client)? + .sign(key) + .execute(&client) + .await; + + assert!(result.is_err()); + if let Err(err) = result { + let err_str = err.to_string(); + assert!( + err_str.contains("HOOK_ID_REPEATED_IN_CREATION_DETAILS") + || err_str.contains("HookIdRepeatedInCreationDetails"), + "Expected HOOK_ID_REPEATED_IN_CREATION_DETAILS error, got: {}", + err_str + ); + } + + Ok(()) +} + +#[tokio::test] +async fn can_update_account_hook_with_admin_key() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + + let account_key = PrivateKey::generate_ed25519(); + let hook_admin_key = PrivateKey::generate_ed25519(); + + // Create account + let create_receipt = AccountCreateTransaction::new() + .key(account_key.public_key()) + .initial_balance(Hbar::new(1)) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let account_id = create_receipt.account_id.unwrap(); + + // Add hook with admin key + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![]); + + let mut hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + hook_details.admin_key = Some(hook_admin_key.public_key().into()); + + let update_receipt = AccountUpdateTransaction::new() + .account_id(account_id) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(update_receipt.status, Status::Success); + + Ok(()) +} diff --git a/tests/e2e/account/mod.rs b/tests/e2e/account/mod.rs index a51f9e907..af2dfdb16 100644 --- a/tests/e2e/account/mod.rs +++ b/tests/e2e/account/mod.rs @@ -1,3 +1,5 @@ +mod account_create_with_hooks; +mod account_update_with_hooks; mod allowance_approve; mod allowance_delete; mod balance; diff --git a/tests/e2e/contract/create_with_hooks.rs b/tests/e2e/contract/create_with_hooks.rs new file mode 100644 index 000000000..0a8d7af98 --- /dev/null +++ b/tests/e2e/contract/create_with_hooks.rs @@ -0,0 +1,264 @@ +use assert_matches::assert_matches; +use hedera::{ + ContractCreateTransaction, + ContractDeleteTransaction, + ContractFunctionParameters, + ContractId, + ContractInfoQuery, + EvmHookSpec, + FileDeleteTransaction, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, + LambdaStorageSlot, + LambdaStorageUpdate, + PrivateKey, + Status, +}; + +use super::bytecode_file_id; +use crate::common::{ + setup_nonfree, + TestEnvironment, +}; + +const HOOK_BYTECODE: &str = "6080604052348015600e575f5ffd5b506103da8061001c5f395ff3fe60806040526004361061001d575f3560e01c80630b6c5c0414610021575b5f5ffd5b61003b6004803603810190610036919061021c565b610051565b60405161004891906102ed565b60405180910390f35b5f61016d73ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16146100c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100b990610386565b60405180910390fd5b60019050979650505050505050565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610102826100d9565b9050919050565b610112816100f8565b811461011c575f5ffd5b50565b5f8135905061012d81610109565b92915050565b5f819050919050565b61014581610133565b811461014f575f5ffd5b50565b5f813590506101608161013c565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261018757610186610166565b5b8235905067ffffffffffffffff8111156101a4576101a361016a565b5b6020830191508360018202830111156101c0576101bf61016e565b5b9250929050565b5f5f83601f8401126101dc576101db610166565b5b8235905067ffffffffffffffff8111156101f9576101f861016a565b5b6020830191508360018202830111156102155761021461016e565b5b9250929050565b5f5f5f5f5f5f5f60a0888a031215610237576102366100d1565b5b5f6102448a828b0161011f565b97505060206102558a828b01610152565b96505060406102668a828b01610152565b955050606088013567ffffffffffffffff811115610287576102866100d5565b5b6102938a828b01610172565b9450945050608088013567ffffffffffffffff8111156102b6576102b56100d5565b5b6102c28a828b016101c7565b925092505092959891949750929550565b5f8115159050919050565b6102e7816102d3565b82525050565b5f6020820190506103005f8301846102de565b92915050565b5f82825260208201905092915050565b7f436f6e74726163742063616e206f6e6c792062652063616c6c656420617320615f8201527f20686f6f6b000000000000000000000000000000000000000000000000000000602082015250565b5f610370602583610306565b915061037b82610316565b604082019050919050565b5f6020820190508181035f83015261039d81610364565b905091905056fea2646970667358221220a8c76458204f8bb9a86f59ec2f0ccb7cbe8ae4dcb65700c4b6ee91a39404083a64736f6c634300081e0033"; + +async fn create_hook_contract(client: &hedera::Client) -> anyhow::Result { + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .execute(client) + .await? + .get_receipt(client) + .await?; + + Ok(receipt.contract_id.unwrap()) +} + +#[tokio::test] +async fn basic_contract_create() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let file_id = bytecode_file_id(&client, operator_key).await?; + + let contract_id = ContractCreateTransaction::new() + .admin_key(operator_key) + .gas(2000000) + .constructor_parameters( + ContractFunctionParameters::new().add_string("Hello from Hedera.").to_bytes(None), + ) + .bytecode_file_id(file_id) + .contract_memo("[e2e::ContractCreateTransaction]") + .execute(&client) + .await? + .get_receipt(&client) + .await? + .contract_id + .unwrap(); + + let info = ContractInfoQuery::new().contract_id(contract_id).execute(&client).await?; + + assert_eq!(info.contract_id, contract_id); + assert_eq!(info.account_id.to_string(), info.contract_id.to_string()); + assert!(info.admin_key.is_some()); + assert_eq!(info.storage, 128); + assert_eq!(info.contract_memo, "[e2e::ContractCreateTransaction]"); + + ContractDeleteTransaction::new() + .contract_id(contract_id) + .transfer_account_id(op.account_id) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + FileDeleteTransaction::new() + .file_id(file_id) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn contract_create_with_lambda_hook() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let lambda_contract_id = create_hook_contract(&client).await?; + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert!(receipt.contract_id.is_some()); + Ok(()) +} + +#[tokio::test] +async fn contract_create_with_hook_and_storage_updates() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let storage_slot = LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(contract_id)), vec![storage_update]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert!(receipt.contract_id.is_some()); + Ok(()) +} + +#[tokio::test] +async fn contract_create_fails_when_lambda_hook_missing_contract_id() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let storage_slot = LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(None), vec![storage_update]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let result = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches!(result, Err(hedera::Error::ReceiptStatus { status, .. }) if status == Status::InvalidHookCreationSpec); + + Ok(()) +} + +#[tokio::test] +async fn contract_create_fails_with_duplicate_hook_id() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let lambda_contract_id = create_hook_contract(&client).await?; + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 1, + Some(lambda_hook.clone()), + ); + + let same_hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let result = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .add_hook(hook_details) + .add_hook(same_hook_details) + .execute(&client) + .await; + + assert_matches!(result, Err(hedera::Error::TransactionPreCheckStatus { status, .. }) if status == Status::HookIdRepeatedInCreationDetails); + + Ok(()) +} + +#[tokio::test] +async fn contract_create_with_hook_admin_key() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let admin_key = PrivateKey::generate_ed25519(); + + let storage_slot = LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(contract_id)), vec![storage_update]); + + let mut hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + hook_details.admin_key = Some(admin_key.public_key().into()); + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .add_hook(hook_details) + .freeze_with(&client)? + .sign(admin_key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert!(receipt.contract_id.is_some()); + Ok(()) +} diff --git a/tests/e2e/contract/mod.rs b/tests/e2e/contract/mod.rs index 4f59fc121..f068815cd 100644 --- a/tests/e2e/contract/mod.rs +++ b/tests/e2e/contract/mod.rs @@ -9,11 +9,13 @@ use hedera::{ mod bytecode; mod create; mod create_flow; +mod create_with_hooks; mod delete; mod execute; mod info; mod nonce_info; mod update; +mod update_with_hooks; enum ContractAdminKey { Operator, diff --git a/tests/e2e/contract/update_with_hooks.rs b/tests/e2e/contract/update_with_hooks.rs new file mode 100644 index 000000000..5302c0ef9 --- /dev/null +++ b/tests/e2e/contract/update_with_hooks.rs @@ -0,0 +1,463 @@ +use assert_matches::assert_matches; +use hedera::{ + AccountId, + ContractCreateTransaction, + ContractDeleteTransaction, + ContractFunctionParameters, + ContractId, + ContractInfoQuery, + ContractUpdateTransaction, + EvmHookSpec, + FileDeleteTransaction, + FileId, + Hbar, + HookCreationDetails, + HookExtensionPoint, + LambdaEvmHook, + LambdaStorageSlot, + LambdaStorageUpdate, + PublicKey, + Status, +}; + +use super::bytecode_file_id; +use crate::common::{ + setup_nonfree, + TestEnvironment, +}; + +const HOOK_BYTECODE: &str = "6080604052348015600e575f5ffd5b506103da8061001c5f395ff3fe60806040526004361061001d575f3560e01c80630b6c5c0414610021575b5f5ffd5b61003b6004803603810190610036919061021c565b610051565b60405161004891906102ed565b60405180910390f35b5f61016d73ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16146100c2626040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100b990610386565b60405180910390fd5b60019050979650505050505050565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610102826100d9565b9050919050565b610112816100f8565b811461011c575f5ffd5b50565b5f8135905061012d81610109565b92915050565b5f819050919050565b61014581610133565b811461014f575f5ffd5b50565b5f813590506101608161013c565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261018757610186610166565b5b8235905067ffffffffffffffff8111156101a4576101a361016a565b5b6020830191508360018202830111156101c0576101bf61016e565b5b9250929050565b5f5f83601f8401126101dc576101db610166565b5b8235905067ffffffffffffffff8111156101f9576101f861016a565b5b6020830191508360018202830111156102155761021461016e565b5b9250929050565b5f5f5f5f5f5f5f60a0888a031215610237576102366100d1565b5b5f6102448a828b0161011f565b97505060206102558a828b01610152565b96505060406102668a828b01610152565b955050606088013567ffffffffffffffff811115610287576102866100d5565b5b6102938a828b01610172565b9450945050608088013567ffffffffffffffff8111156102b6576102b56100d5565b5b6102c28a828b016101c7565b925092505092959891949750929550565b5f8115159050919050565b6102e7816102d3565b82525050565b5f6020820190506103005f8301846102de565b92915050565b5f82825260208201905092915050565b7f436f6e74726163742063616e206f6e6c792062652063616c6c656420617320615f8201527f20686f6f6b000000000000000000000000000000000000000000000000000000602082015250565b5f610370602583610306565b915061037b82610316565b604082019050919050565b5f6020820190508181035f83015261039d81610364565b905091905056fea2646970667358221220a8c76458204f8bb9a86f59ec2f0ccb7cbe8ae4dcb65700c4b6ee91a39404083a64736f6c634300081e0033"; + +async fn create_hook_contract(client: &hedera::Client) -> anyhow::Result { + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .execute(client) + .await? + .get_receipt(client) + .await?; + + Ok(receipt.contract_id.unwrap()) +} + +async fn create_test_contract( + client: &hedera::Client, + operator_key: PublicKey, + operator_account_id: AccountId, +) -> anyhow::Result<(ContractId, FileId)> { + let file_id = bytecode_file_id(client, operator_key).await?; + + let contract_id = ContractCreateTransaction::new() + .admin_key(operator_key) + .gas(2000000) + .constructor_parameters( + ContractFunctionParameters::new().add_string("Hello from Hiero.").to_bytes(None), + ) + .bytecode_file_id(file_id) + .contract_memo("[e2e::ContractCreateTransaction]") + .auto_renew_account_id(operator_account_id) + .execute(client) + .await? + .get_receipt(client) + .await? + .contract_id + .unwrap(); + + Ok((contract_id, file_id)) +} + +#[tokio::test] +async fn basic_contract_update() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + + let info_before = ContractInfoQuery::new().contract_id(contract_id).execute(&client).await?; + + assert_eq!(info_before.contract_id, contract_id); + assert_eq!(info_before.contract_memo, "[e2e::ContractCreateTransaction]"); + + ContractUpdateTransaction::new() + .contract_id(contract_id) + .contract_memo("[e2e::ContractUpdateTransaction]") + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let info_after = ContractInfoQuery::new().contract_id(contract_id).execute(&client).await?; + + assert_eq!(info_after.contract_id, contract_id); + assert_eq!(info_after.contract_memo, "[e2e::ContractUpdateTransaction]"); + + ContractDeleteTransaction::new() + .contract_id(contract_id) + .transfer_account_id(op.account_id) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn contract_update_fails_when_contract_id_not_set() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (_contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + + let result = ContractUpdateTransaction::new() + .contract_memo("[e2e::ContractUpdateTransaction]") + .execute(&client) + .await; + + assert_matches!(result, Err(hedera::Error::TransactionPreCheckStatus { status, .. }) if status == Status::InvalidContractId); + + Ok(()) +} + +#[tokio::test] +async fn contract_update_auto_renew_account_id_to_zero() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + + let info_before = ContractInfoQuery::new().contract_id(contract_id).execute(&client).await?; + + assert_eq!(info_before.contract_id, contract_id); + assert_eq!(info_before.auto_renew_account_id, Some(op.account_id)); + + let result = ContractUpdateTransaction::new() + .contract_id(contract_id) + .contract_memo("[e2e::ContractUpdateTransaction]") + .auto_renew_account_id(AccountId::new(0, 0, 0)) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + // Setting auto_renew_account_id to 0.0.0 is rejected by the network + assert_matches!(result, Err(hedera::Error::ReceiptStatus { status, .. }) if status == Status::InvalidAutorenewAccount); + + ContractDeleteTransaction::new() + .contract_id(contract_id) + .transfer_account_id(op.account_id) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn contract_update_adds_basic_lambda_evm_hook() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn contract_update_fails_with_duplicate_hook_ids() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details1 = HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 1, + Some(lambda_hook.clone()), + ); + + let hook_details2 = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let result = ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details1) + .add_hook(hook_details2) + .execute(&client) + .await; + + assert_matches!(result, Err(hedera::Error::TransactionPreCheckStatus { status, .. }) if status == Status::HookIdRepeatedInCreationDetails); + + Ok(()) +} + +#[tokio::test] +async fn contract_update_fails_when_hook_id_in_use() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = HookCreationDetails::new( + HookExtensionPoint::AccountAllowanceHook, + 1, + Some(lambda_hook.clone()), + ); + + ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let duplicate_hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let result = ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(duplicate_hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches!(result, Err(hedera::Error::ReceiptStatus { status, .. }) if status == Status::HookIdInUse); + + Ok(()) +} + +#[tokio::test] +async fn contract_update_adds_lambda_evm_hook_with_storage_updates() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let storage_slot = LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let lambda_hook = + LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![storage_update]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn contract_update_deletes_hook_by_id() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let delete_receipt = ContractUpdateTransaction::new() + .contract_id(contract_id) + .delete_hook(1) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(delete_receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn contract_update_fails_when_deleting_nonexistent_hook() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + let result = ContractUpdateTransaction::new() + .contract_id(contract_id) + .delete_hook(999) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches!(result, Err(hedera::Error::ReceiptStatus { status, .. }) if status == Status::HookNotFound); + + Ok(()) +} + +#[tokio::test] +async fn contract_update_fails_when_adding_and_deleting_same_hook_id() -> anyhow::Result<()> { + let Some(TestEnvironment { config, client }) = setup_nonfree() else { + return Ok(()); + }; + + let Some(op) = &config.operator else { + log::debug!("skipping test due to missing operator"); + return Ok(()); + }; + + let operator_key = op.private_key.public_key(); + let (contract_id, _file_id) = + create_test_contract(&client, operator_key, op.account_id).await?; + let lambda_contract_id = create_hook_contract(&client).await?; + + let lambda_hook = LambdaEvmHook::new(EvmHookSpec::new(Some(lambda_contract_id)), vec![]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + let result = ContractUpdateTransaction::new() + .contract_id(contract_id) + .add_hook(hook_details) + .delete_hook(1) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert_matches!(result, Err(hedera::Error::ReceiptStatus { status, .. }) if status == Status::HookNotFound); + + ContractDeleteTransaction::new() + .contract_id(contract_id) + .transfer_account_id(op.account_id) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + Ok(()) +} diff --git a/tests/e2e/hooks/lambda_sstore.rs b/tests/e2e/hooks/lambda_sstore.rs new file mode 100644 index 000000000..2149f51bf --- /dev/null +++ b/tests/e2e/hooks/lambda_sstore.rs @@ -0,0 +1,257 @@ +use assert_matches::assert_matches; +use hedera::{ + AccountCreateTransaction, + ContractCreateTransaction, + ContractId, + EvmHookSpec, + Hbar, + HookCreationDetails, + HookEntityId, + HookExtensionPoint, + HookId, + LambdaEvmHook, + LambdaSStoreTransaction, + LambdaStorageSlot, + LambdaStorageUpdate, + PrivateKey, + Status, +}; + +use crate::common::{ + setup_nonfree, + TestEnvironment, +}; + +const HOOK_BYTECODE: &str = "6080604052348015600e575f5ffd5b506103da8061001c5f395ff3fe60806040526004361061001d575f3560e01c80630b6c5c0414610021575b5f5ffd5b61003b6004803603810190610036919061021c565b610051565b60405161004891906102ed565b60405180910390f35b5f61016d73ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16146100c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100b990610386565b60405180910390fd5b60019050979650505050505050565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610102826100d9565b9050919050565b610112816100f8565b811461011c575f5ffd5b50565b5f8135905061012d81610109565b92915050565b5f819050919050565b61014581610133565b811461014f575f5ffd5b50565b5f813590506101608161013c565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261018757610186610166565b5b8235905067ffffffffffffffff8111156101a4576101a361016a565b5b6020830191508360018202830111156101c0576101bf61016e565b5b9250929050565b5f5f83601f8401126101dc576101db610166565b5b8235905067ffffffffffffffff8111156101f9576101f861016a565b5b6020830191508360018202830111156102155761021461016e565b5b9250929050565b5f5f5f5f5f5f5f60a0888a031215610237576102366100d1565b5b5f6102448a828b0161011f565b97505060206102558a828b01610152565b96505060406102668a828b01610152565b955050606088013567ffffffffffffffff811115610287576102866100d5565b5b6102938a828b01610172565b9450945050608088013567ffffffffffffffff8111156102b6576102b56100d5565b5b6102c28a828b016101c7565b925092505092959891949750929550565b5f8115159050919050565b6102e7816102d3565b82525050565b5f6020820190506103005f8301846102de565b92915050565b5f82825260208201905092915050565b7f436f6e74726163742063616e206f6e6c792062652063616c6c656420617320615f8201527f20686f6f6b000000000000000000000000000000000000000000000000000000602082015250565b5f610370602583610306565b915061037b82610316565b604082019050919050565b5f6020820190508181035f83015261039d81610364565b905091905056fea2646970667358221220a8c76458204f8bb9a86f59ec2f0ccb7cbe8ae4dcb65700c4b6ee91a39404083a64736f6c634300081e0033"; + +async fn create_hook_contract(client: &hedera::Client) -> anyhow::Result { + let bytecode = hex::decode(HOOK_BYTECODE)?; + + let receipt = ContractCreateTransaction::new() + .bytecode(bytecode) + .gas(300_000) + .execute(client) + .await? + .get_receipt(client) + .await?; + + Ok(receipt.contract_id.unwrap()) +} + +async fn create_account_with_hook( + client: &hedera::Client, + contract_id: ContractId, +) -> anyhow::Result<(hedera::AccountId, PrivateKey, HookId)> { + let account_key = PrivateKey::generate_ed25519(); + + // Create initial storage slot (use minimal representation - no leading zeros) + let storage_slot = LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![0x05, 0x06, 0x07, 0x08]); + + // Create lambda hook with storage + let spec = EvmHookSpec::new(Some(contract_id)); + let lambda_hook = LambdaEvmHook::new(spec, vec![LambdaStorageUpdate::StorageSlot(storage_slot)]); + + let hook_details = + HookCreationDetails::new(HookExtensionPoint::AccountAllowanceHook, 1, Some(lambda_hook)); + + // Create account with the hook + let receipt = AccountCreateTransaction::new() + .key(account_key.public_key()) + .initial_balance(Hbar::new(1)) + .add_hook(hook_details) + .freeze_with(client)? + .sign(account_key.clone()) + .execute(client) + .await? + .get_receipt(client) + .await?; + + let account_id = receipt.account_id.unwrap(); + + // Create hook ID + let entity_id = HookEntityId::new(Some(account_id)); + let hook_id = HookId::new(Some(entity_id), 1); + + Ok((account_id, account_key, hook_id)) +} + +#[tokio::test] +async fn can_update_storage_slots_with_valid_signatures() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let (_account_id, account_key, hook_id) = create_account_with_hook(&client, contract_id).await?; + + // Create new storage update (use minimal representation - no leading zeros) + let new_storage_slot = LambdaStorageSlot::new(vec![0x09, 0x0a, 0x0b, 0x0c], vec![0x0d, 0x0e, 0x0f, 0x10]); + let storage_update = LambdaStorageUpdate::StorageSlot(new_storage_slot); + + // Update storage slots + let receipt = LambdaSStoreTransaction::new() + .hook_id(hook_id) + .add_storage_update(storage_update) + .freeze_with(&client)? + .sign(account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(receipt.status, Status::Success); + + Ok(()) +} + +#[tokio::test] +async fn cannot_update_more_than_256_storage_slots() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let (_account_id, account_key, hook_id) = create_account_with_hook(&client, contract_id).await?; + + // Create 257 storage slots (exceeds limit) + let storage_slot = LambdaStorageSlot::new(vec![0x01, 0x02, 0x03, 0x04], vec![0x05, 0x06, 0x07, 0x08]); + let storage_updates: Vec = + (0..257).map(|_| LambdaStorageUpdate::StorageSlot(storage_slot.clone())).collect(); + + let result = LambdaSStoreTransaction::new() + .hook_id(hook_id) + .storage_updates(storage_updates) + .freeze_with(&client)? + .sign(account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert!(result.is_err()); + if let Err(err) = result { + let err_str = err.to_string(); + assert!( + err_str.contains("TOO_MANY_LAMBDA_STORAGE_UPDATES") + || err_str.contains("TooManyLambdaStorageUpdates"), + "Expected TOO_MANY_LAMBDA_STORAGE_UPDATES error, got: {}", + err_str + ); + } + + Ok(()) +} + +#[tokio::test] +async fn cannot_update_storage_with_invalid_signature() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let (_account_id, _account_key, hook_id) = create_account_with_hook(&client, contract_id).await?; + + // Use wrong key + let invalid_key = PrivateKey::generate_ed25519(); + + let storage_slot = LambdaStorageSlot::new(vec![0x31, 0x32, 0x33, 0x34], vec![0x35, 0x36, 0x37, 0x38]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let result = LambdaSStoreTransaction::new() + .hook_id(hook_id) + .add_storage_update(storage_update) + .freeze_with(&client)? + .sign(invalid_key) + .execute(&client) + .await; + + assert!(result.is_err()); + if let Err(err) = result { + let err_str = err.to_string(); + assert!( + err_str.contains("INVALID_SIGNATURE") || err_str.contains("InvalidSignature"), + "Expected INVALID_SIGNATURE error, got: {}", + err_str + ); + } + + Ok(()) +} + +#[tokio::test] +async fn cannot_update_storage_for_nonexistent_hook() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let (account_id, account_key, _hook_id) = create_account_with_hook(&client, contract_id).await?; + + // Use non-existent hook ID + let entity_id = HookEntityId::new(Some(account_id)); + let nonexistent_hook_id = HookId::new(Some(entity_id), 999); + + let storage_slot = LambdaStorageSlot::new(vec![0x41, 0x42, 0x43, 0x44], vec![0x45, 0x46, 0x47, 0x48]); + let storage_update = LambdaStorageUpdate::StorageSlot(storage_slot); + + let result = LambdaSStoreTransaction::new() + .hook_id(nonexistent_hook_id) + .add_storage_update(storage_update) + .freeze_with(&client)? + .sign(account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await; + + assert!(result.is_err()); + if let Err(err) = result { + let err_str = err.to_string(); + assert!( + err_str.contains("HOOK_NOT_FOUND") || err_str.contains("HookNotFound"), + "Expected HOOK_NOT_FOUND error, got: {}", + err_str + ); + } + + Ok(()) +} + +#[tokio::test] +async fn can_update_multiple_storage_slots() -> anyhow::Result<()> { + let Some(TestEnvironment { config: _, client }) = setup_nonfree() else { + return Ok(()); + }; + + let contract_id = create_hook_contract(&client).await?; + let (_account_id, account_key, hook_id) = create_account_with_hook(&client, contract_id).await?; + + // Create multiple storage updates + let storage_slot1 = + LambdaStorageSlot::new(vec![0x11, 0x12, 0x13, 0x14], vec![0x15, 0x16, 0x17, 0x18]); + let storage_slot2 = + LambdaStorageSlot::new(vec![0x21, 0x22, 0x23, 0x24], vec![0x25, 0x26, 0x27, 0x28]); + let storage_slot3 = + LambdaStorageSlot::new(vec![0x31, 0x32, 0x33, 0x34], vec![0x35, 0x36, 0x37, 0x38]); + + let storage_updates = vec![ + LambdaStorageUpdate::StorageSlot(storage_slot1), + LambdaStorageUpdate::StorageSlot(storage_slot2), + LambdaStorageUpdate::StorageSlot(storage_slot3), + ]; + + // Update multiple storage slots at once + let receipt = LambdaSStoreTransaction::new() + .hook_id(hook_id) + .storage_updates(storage_updates) + .freeze_with(&client)? + .sign(account_key) + .execute(&client) + .await? + .get_receipt(&client) + .await?; + + assert_matches!(receipt.status, Status::Success); + + Ok(()) +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index d37572058..4595bf259 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -7,7 +7,9 @@ mod contract; mod ethereum_transaction; mod fee_schedules; mod file; +// mod hooks; 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..344364d47 --- /dev/null +++ b/tests/e2e/node/update.rs @@ -0,0 +1,478 @@ +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(()) +} +