diff --git a/Cargo.lock b/Cargo.lock index bc41599a..9646215b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,7 +2025,9 @@ dependencies = [ name = "stellar-axelar-std-derive" version = "0.1.0" dependencies = [ + "heck", "paste", + "proc-macro2", "quote", "soroban-sdk", "stellar-axelar-std", diff --git a/Cargo.toml b/Cargo.toml index 16c4940d..35c5d04b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ rust-version = "1.81.0" [workspace.dependencies] soroban-sdk = { version = "22.0.2" } soroban-token-sdk = { version = "22.0.2" } +proc-macro2 = { version = "1.0" } cfg-if = { version = "1.0" } stellar-axelar-std = { version = "^0.1.0", path = "packages/axelar-std", features = ["derive"] } stellar-axelar-std-derive = { version = "^0.1.0", path = "packages/axelar-std-derive" } diff --git a/contracts/axelar-gateway/src/event.rs b/contracts/axelar-gateway/src/event.rs index 34ce2332..264d5b58 100644 --- a/contracts/axelar-gateway/src/event.rs +++ b/contracts/axelar-gateway/src/event.rs @@ -1,97 +1,32 @@ use core::fmt::Debug; -use cfg_if::cfg_if; -use soroban_sdk::{Address, Bytes, BytesN, Env, IntoVal, String, Symbol, Topics, Val, Vec}; -use stellar_axelar_std::events::Event; +use soroban_sdk::{Address, Bytes, BytesN, String}; +use stellar_axelar_std::IntoEvent; use crate::types::Message; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, IntoEvent)] pub struct ContractCalledEvent { pub caller: Address, pub destination_chain: String, pub destination_address: String, - pub payload: Bytes, pub payload_hash: BytesN<32>, + #[data] + pub payload: Bytes, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, IntoEvent)] pub struct MessageApprovedEvent { pub message: Message, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, IntoEvent)] pub struct MessageExecutedEvent { pub message: Message, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, IntoEvent)] pub struct SignersRotatedEvent { pub epoch: u64, pub signers_hash: BytesN<32>, } - -impl Event for ContractCalledEvent { - fn topics(&self, env: &Env) -> impl Topics + Debug { - ( - Symbol::new(env, "contract_called"), - self.caller.to_val(), - self.destination_chain.to_val(), - self.destination_address.to_val(), - self.payload_hash.to_val(), - ) - } - - fn data(&self, _env: &Env) -> impl IntoVal + Debug { - (self.payload.to_val(),) - } -} - -impl Event for MessageApprovedEvent { - fn topics(&self, env: &Env) -> impl Topics + Debug { - (Symbol::new(env, "message_approved"), self.message.clone()) - } - - fn data(&self, env: &Env) -> impl IntoVal + Debug { - Vec::::new(env) - } -} - -impl Event for MessageExecutedEvent { - fn topics(&self, env: &Env) -> impl Topics + Debug { - (Symbol::new(env, "message_executed"), self.message.clone()) - } - - fn data(&self, env: &Env) -> impl IntoVal + Debug { - Vec::::new(env) - } -} - -impl Event for SignersRotatedEvent { - fn topics(&self, env: &Env) -> impl Topics + Debug { - ( - Symbol::new(env, "signers_rotated"), - self.epoch, - self.signers_hash.to_val(), - ) - } - - fn data(&self, env: &Env) -> impl IntoVal + Debug { - Vec::::new(env) - } -} - -cfg_if! { - if #[cfg(any(test, feature = "testutils"))] { - use stellar_axelar_std::impl_event_testutils; - - impl_event_testutils!( - ContractCalledEvent, - (Symbol, Address, String, String, BytesN<32>), - (Bytes) - ); - impl_event_testutils!(MessageApprovedEvent, (Symbol, Message), ()); - impl_event_testutils!(MessageExecutedEvent, (Symbol, Message), ()); - impl_event_testutils!(SignersRotatedEvent, (Symbol, u64, BytesN<32>), ()); - } -} diff --git a/packages/axelar-std-derive/Cargo.toml b/packages/axelar-std-derive/Cargo.toml index 88384628..fadd0771 100644 --- a/packages/axelar-std-derive/Cargo.toml +++ b/packages/axelar-std-derive/Cargo.toml @@ -9,14 +9,19 @@ publish = true [lib] proc-macro = true +[features] +testutils = [] + [dependencies] +heck = "0.5.0" +proc-macro2 = { workspace = true } quote = "1.0" syn = { version = "2.0", features = ["full"] } [dev-dependencies] paste = { workspace = true } soroban-sdk = { workspace = true, features = ["testutils"] } -stellar-axelar-std = { workspace = true } +stellar-axelar-std = { workspace = true, features = ["testutils"] } [lints] workspace = true diff --git a/packages/axelar-std-derive/src/lib.rs b/packages/axelar-std-derive/src/lib.rs index c7b40ab5..847cea89 100644 --- a/packages/axelar-std-derive/src/lib.rs +++ b/packages/axelar-std-derive/src/lib.rs @@ -1,7 +1,8 @@ +use heck::ToSnakeCase; use proc_macro::TokenStream; use quote::quote; use syn::parse::{Parse, ParseStream}; -use syn::{parse_macro_input, DeriveInput, Error, Ident, Token, Type}; +use syn::{parse_macro_input, DeriveInput, Error, Ident, LitStr, Token, Type}; /// Implements the Operatable interface for a Soroban contract. /// @@ -219,3 +220,200 @@ pub fn derive_upgradable(input: TokenStream) -> TokenStream { } }.into() } + +/// Implements the Event and EventTestUtils traits for a Soroban contract event. +/// +/// Fields without a `#[data]` attribute are used as topics, while fields with `#[data]` are used as event data. +/// The event name can be specified with `#[event_name(...)]` or will default to the struct name in snake_case (minus "Event" suffix). +/// +/// # Example +/// ```rust +/// # mod test { +/// use core::fmt::Debug; +/// use stellar_axelar_std::events::Event; +/// use stellar_axelar_std_derive::IntoEvent; +/// use soroban_sdk::{Address, contract, contractimpl, Env, String}; +/// +/// #[derive(Debug, PartialEq, IntoEvent)] +/// #[event_name("transfer")] +/// pub struct TransferEvent { +/// pub from: Address, +/// pub to: Address, +/// #[data] +/// pub amount: String, +/// } +/// +/// #[contract] +/// pub struct Token; +/// +/// #[contractimpl] +/// impl Token { +/// pub fn transfer(env: &Env, to: Address, amount: String) { +/// // ... transfer logic ... +/// +/// // Generates event with: +/// // - Topics: ["transfer", contract_address, to] +/// // - Data: [amount] +/// TransferEvent { +/// from: env.current_contract_address(), +/// to, +/// amount, +/// }.emit(env); +/// } +/// } +/// } +/// ``` +#[proc_macro_derive(IntoEvent, attributes(data, event_name))] +pub fn derive_into_event(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let event_impl = derive_event_impl(&input); + + #[cfg(any(test, feature = "testutils"))] + let event_impl = { + let event_test_impl = derive_event_testutils_impl(&input); + quote! { + #event_impl + #event_test_impl + } + }; + + event_impl.into() +} + +fn derive_event_impl(input: &DeriveInput) -> proc_macro2::TokenStream { + let name = &input.ident; + let event_name = event_name_snake_case(input); + let ((topic_idents, _), (data_idents, _)) = event_struct_fields(input); + + quote! { + impl stellar_axelar_std::events::Event for #name { + fn topics(&self, env: &soroban_sdk::Env) -> impl soroban_sdk::Topics + core::fmt::Debug { + ( + soroban_sdk::Symbol::new(env, #event_name), + #(soroban_sdk::IntoVal::::into_val(&self.#topic_idents, env),)* + ) + } + + fn data(&self, env: &soroban_sdk::Env) -> impl soroban_sdk::IntoVal + core::fmt::Debug { + let vec: soroban_sdk::Vec = soroban_sdk::vec![env, #(soroban_sdk::IntoVal::<_, soroban_sdk::Val>::into_val(&self.#data_idents, env))*]; + vec + } + + fn emit(self, env: &soroban_sdk::Env) { + env.events().publish(self.topics(env), self.data(env)); + } + } + } +} +#[cfg(any(test, feature = "testutils"))] +fn derive_event_testutils_impl(input: &DeriveInput) -> proc_macro2::TokenStream { + let name = &input.ident; + let ((_, topic_types), (_, data_types)) = event_struct_fields(input); + event_testutils(name, topic_types, data_types) +} + +#[cfg(any(test, feature = "testutils"))] +fn event_testutils( + name: &Ident, + topic_types: Vec<&Type>, + data_types: Vec<&Type>, +) -> proc_macro2::TokenStream { + quote! { + impl stellar_axelar_std::events::EventTestutils for #name { + fn matches(self, env: &soroban_sdk::Env, event: &(soroban_sdk::Address, soroban_sdk::Vec, soroban_sdk::Val)) -> bool { + use soroban_sdk::IntoVal; + use stellar_axelar_std::events::Event; + + Self::standardized_fmt(env, event) == Self::standardized_fmt(env, &(event.0.clone(), self.topics(env).into_val(env), self.data(env).into_val(env))) + } + + #[allow(unused_assignments)] + #[allow(unused_variables)] + #[allow(unused_mut)] + fn standardized_fmt(env: &soroban_sdk::Env, (contract_id, topics, data): &(soroban_sdk::Address, soroban_sdk::Vec, soroban_sdk::Val)) -> std::string::String { + use soroban_sdk::TryFromVal; + + let mut topics_output: std::vec::Vec = std::vec![]; + + let event_name = topics.get(0).expect("event name topic missing"); + topics_output.push(std::format!("{:?}", soroban_sdk::Symbol::try_from_val(env, &event_name) + .expect("event name should be a Symbol"))); + + let mut i = 1; + #( + let topic = topics.get(i).expect("the number of topics does not match this function's definition"); + topics_output.push(std::format!("{:?}", <#topic_types>::try_from_val(env, &topic) + .expect("given topic value does not match the expected type"))); + + i += 1; + )* + + let data = soroban_sdk::Vec::::try_from_val(env, data) + .expect("data should be defined as a vector-compatible type"); + + let mut data_output: std::vec::Vec = std::vec![]; + + let mut i = 0; + #( + let data_entry = data.get(i).expect("the number of data entries does not match this function's definition"); + data_output.push(std::format!("{:?}", <#data_types>::try_from_val(env, &data_entry) + .expect("given data value does not match the expected type"))); + + i += 1; + )* + + std::format!("contract: {:?}\ntopics: ({})\ndata: ({})", + contract_id, + topics_output.join(", "), + data_output.join(", ") + ) + } + } + } +} + +fn event_name_snake_case(input: &DeriveInput) -> String { + input + .attrs + .iter() + .find(|attr| attr.path().is_ident("event_name")) + .map(|attr| attr.parse_args::().unwrap().value()) + .unwrap_or_else(|| { + input + .ident + .to_string() + .strip_suffix("Event") + .unwrap() + .to_snake_case() + }) +} + +type EventIdent<'a> = Vec<&'a Ident>; +type EventType<'a> = Vec<&'a Type>; +type EventStructFields<'a> = (EventIdent<'a>, EventType<'a>); + +fn event_struct_fields(input: &DeriveInput) -> (EventStructFields, EventStructFields) { + let syn::Data::Struct(data_struct) = &input.data else { + panic!("IntoEvent can only be derived for structs"); + }; + + let mut topic_idents = Vec::new(); + let mut topic_types = Vec::new(); + let mut data_idents = Vec::new(); + let mut data_types = Vec::new(); + + for field in data_struct.fields.iter() { + if let Some(ident) = field.ident.as_ref() { + if field.attrs.iter().any(|attr| attr.path().is_ident("data")) { + data_idents.push(ident); + data_types.push(&field.ty); + } else { + topic_idents.push(ident); + topic_types.push(&field.ty); + } + } + } + + ((topic_idents, topic_types), (data_idents, data_types)) +} diff --git a/packages/axelar-std/Cargo.toml b/packages/axelar-std/Cargo.toml index 62afd8e9..9433fbf3 100644 --- a/packages/axelar-std/Cargo.toml +++ b/packages/axelar-std/Cargo.toml @@ -19,7 +19,7 @@ stellar-axelar-std-derive = { workspace = true, optional = true } goldie = { workspace = true } paste = { workspace = true } soroban-sdk = { workspace = true, features = ["testutils"] } -stellar-axelar-std-derive = { workspace = true } +stellar-axelar-std-derive = { workspace = true, features = ["testutils"] } [features] testutils = ["soroban-sdk/testutils", "hex"] diff --git a/packages/axelar-std/src/events.rs b/packages/axelar-std/src/events.rs index e932dd23..afd51df1 100644 --- a/packages/axelar-std/src/events.rs +++ b/packages/axelar-std/src/events.rs @@ -4,7 +4,7 @@ use soroban_sdk::{Env, IntoVal, Topics, Val, Vec}; #[cfg(any(test, feature = "testutils"))] pub use testutils::*; -pub trait Event: Debug + PartialEq { +pub trait Event: Debug + PartialEq + Sized { fn topics(&self, env: &Env) -> impl Topics + Debug; /// A default empty tuple/vector is used for event data, since majority of events only use topics. @@ -12,7 +12,7 @@ pub trait Event: Debug + PartialEq { Vec::::new(env) } - fn emit(&self, env: &Env) { + fn emit(self, env: &Env) { env.events().publish(self.topics(env), self.data(env)); } } @@ -25,7 +25,7 @@ mod testutils { use crate::events::Event; pub trait EventTestutils: Event { - fn matches(&self, env: &Env, event: &(Address, Vec, Val)) -> bool; + fn matches(self, env: &Env, event: &(Address, Vec, Val)) -> bool; fn standardized_fmt( env: &Env, @@ -61,7 +61,7 @@ mod testutils { macro_rules! impl_event_testutils { ($event_type:ty, ($($topic_type:ty),*), ($($data_type:ty),*)) => { impl $crate::events::EventTestutils for $event_type { - fn matches(&self, env: &soroban_sdk::Env, event: &(soroban_sdk::Address, soroban_sdk::Vec, soroban_sdk::Val)) -> bool { + fn matches(self, env: &soroban_sdk::Env, event: &(soroban_sdk::Address, soroban_sdk::Vec, soroban_sdk::Val)) -> bool { use soroban_sdk::IntoVal; Self::standardized_fmt(env, event) == Self::standardized_fmt(env, &(event.0.clone(), self.topics(env).into_val(env), self.data(env).into_val(env))) @@ -115,7 +115,7 @@ mod test { use crate::events::{Event, EventTestutils}; use crate::{events, impl_event_testutils}; - #[derive(Debug, PartialEq, Eq)] + #[derive(Debug, PartialEq, Eq, Clone)] struct TestEvent { topic1: Symbol, topic2: String, @@ -152,7 +152,7 @@ mod test { let contract = env.register(Contract, ()); env.as_contract(&contract, || { - expected.emit(&env); + expected.clone().emit(&env); }); assert!(expected.matches(&env, &env.events().all().last().unwrap()));