diff --git a/src/hooks/evm_hook_call.rs b/src/hooks/evm_hook_call.rs new file mode 100644 index 00000000..3698ceb6 --- /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 00000000..da36956d --- /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 00000000..395de3da --- /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 00000000..60941530 --- /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 00000000..83eaf30d --- /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 00000000..e3fbac6a --- /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 00000000..ff48fb50 --- /dev/null +++ b/src/hooks/hook_entity_id.rs @@ -0,0 +1,98 @@ +use crate::account::AccountId; +use crate::contract::ContractId; +use crate::ledger_id::RefLedgerId; +use hedera_proto::services; +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 00000000..0df3a250 --- /dev/null +++ b/src/hooks/hook_extension_point.rs @@ -0,0 +1,81 @@ +/// 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 00000000..971fc5f1 --- /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 00000000..6b7ee3be --- /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 00000000..476122f4 --- /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_update.rs b/src/hooks/lambda_storage_update.rs new file mode 100644 index 00000000..54e305d2 --- /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 00000000..e66bb31e --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,35 @@ +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_s_store_transaction::{ + LambdaSStoreTransaction, + LambdaSStoreTransactionData, +}; +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/tests/e2e/hooks/lambda_sstore.rs b/tests/e2e/hooks/lambda_sstore.rs new file mode 100644 index 00000000..acca7d07 --- /dev/null +++ b/tests/e2e/hooks/lambda_sstore.rs @@ -0,0 +1,255 @@ +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(()) +} +