From ff65ed0259a18425c15d250dcc30ad19ab093dc7 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Wed, 11 Mar 2026 02:55:22 +0000 Subject: [PATCH 01/23] fix(macros): prevent cross-entity instruction hook contamination When multiple entities are defined in the same #[hyperstack] module, the macro system was collecting all PDA registrations from ALL entities and passing them to EVERY entity. This caused entities to receive instruction hooks for instructions they didn't reference. Example: In the metaplex stack, Collection was getting hooks for CreateMetadataAccountV3 (which only NFTMetadata should have), causing CreateMetadataAccountV3 instructions to be incorrectly routed to the Collection entity at runtime. Fix: Collect PDA registrations per-entity using a new function collect_pda_registrations_per_entity() that returns a HashMap keyed by entity name. Each entity now only receives its own PDA registrations. Closes: metaplex CreateMetadataAccountV3 routing to wrong entity --- hyperstack-macros/src/stream_spec/idl_spec.rs | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/idl_spec.rs b/hyperstack-macros/src/stream_spec/idl_spec.rs index 6a770ff5..e5b5136a 100644 --- a/hyperstack-macros/src/stream_spec/idl_spec.rs +++ b/hyperstack-macros/src/stream_spec/idl_spec.rs @@ -166,6 +166,10 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea let mut resolver_hooks: Vec = Vec::new(); let mut pda_registrations: Vec = Vec::new(); + // Collect per-entity PDA registrations to avoid cross-entity contamination + let per_entity_pda_regs = + collect_pda_registrations_per_entity(&entity_structs, §ion_structs); + if let Some((_, items)) = &module.content { for item in items { if let Item::Struct(item_struct) = item { @@ -182,6 +186,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea } } + // Keep collect_register_from_specs for backwards compatibility with resolver_hooks collect_register_from_specs( &entity_structs, §ion_structs, @@ -263,13 +268,16 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea let result = process_entity_struct_with_idl( entity_struct.clone(), - entity_name, + entity_name.clone(), section_structs.clone(), has_game_event, &stack_name, &idl_lookup, resolver_hooks.clone(), - pda_registrations.clone(), + per_entity_pda_regs + .get(&entity_name) + .cloned() + .unwrap_or_default(), ); for hook in &result.auto_resolver_hooks { @@ -579,3 +587,52 @@ fn collect_register_from_specs( } } } + +/// Collect PDA registrations per-entity to prevent cross-entity contamination. +/// +/// This function returns a HashMap where the key is the entity name and the value +/// is the list of PDA registrations for that specific entity only. +fn collect_pda_registrations_per_entity( + entity_structs: &[syn::ItemStruct], + _section_structs: &HashMap, +) -> HashMap> { + let mut per_entity_regs: HashMap> = HashMap::new(); + + for entity_struct in entity_structs { + let entity_name = parse::parse_entity_name(&entity_struct.attrs) + .unwrap_or_else(|| entity_struct.ident.to_string()); + let mut entity_regs: Vec = Vec::new(); + + if let syn::Fields::Named(fields) = &entity_struct.fields { + for field in &fields.named { + let field_name = field + .ident + .as_ref() + .map(|i| i.to_string()) + .unwrap_or_default(); + for attr in &field.attrs { + if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, &field_name) { + for map_attr in &map_attrs { + if !map_attr.register_from.is_empty() { + for rf in &map_attr.register_from { + entity_regs.push(parse::RegisterPdaAttribute { + instruction_path: rf.instruction_path.clone(), + pda_field: rf.pda_field.clone(), + primary_key_field: rf.primary_key_field.clone(), + lookup_name: "default_pda_lookup".to_string(), + }); + } + } + } + } + } + } + } + + if !entity_regs.is_empty() { + per_entity_regs.insert(entity_name, entity_regs); + } + } + + per_entity_regs +} From 04486af36571ab761ec4804fefcf3dadd23db7a9 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:46:51 +0000 Subject: [PATCH 02/23] feat(idl): add packed representation support to IdlRepr --- hyperstack-idl/src/types.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hyperstack-idl/src/types.rs b/hyperstack-idl/src/types.rs index 65881346..6255ceef 100644 --- a/hyperstack-idl/src/types.rs +++ b/hyperstack-idl/src/types.rs @@ -233,6 +233,8 @@ pub enum IdlTypeDefinedInner { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct IdlRepr { pub kind: String, + #[serde(default)] + pub packed: Option, } /// Account serialization format as specified in the IDL. From 178cffc06ef0276d7386055d68305d7822152851 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:47:00 +0000 Subject: [PATCH 03/23] chore: update dependencies in ore stack and examples --- examples/ore-server/Cargo.lock | 16 +- hyperstack-macros/src/idl_codegen.rs | 348 +++++++++++++++++++- stacks/ore/.hyperstack/OreStream.stack.json | 18 +- stacks/ore/Cargo.lock | 12 + 4 files changed, 367 insertions(+), 27 deletions(-) diff --git a/examples/ore-server/Cargo.lock b/examples/ore-server/Cargo.lock index faec4158..956c0a4b 100644 --- a/examples/ore-server/Cargo.lock +++ b/examples/ore-server/Cargo.lock @@ -1160,6 +1160,16 @@ dependencies = [ "yellowstone-vixen-yellowstone-grpc-source", ] +[[package]] +name = "hyperstack-idl" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "strsim", +] + [[package]] name = "hyperstack-interpreter" version = "0.5.3" @@ -1168,6 +1178,7 @@ dependencies = [ "dashmap", "futures", "hex", + "hyperstack-idl", "hyperstack-macros", "lru", "prost 0.13.5", @@ -1188,6 +1199,7 @@ version = "0.5.3" dependencies = [ "bs58", "hex", + "hyperstack-idl", "proc-macro2", "quote", "serde", @@ -1950,9 +1962,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", diff --git a/hyperstack-macros/src/idl_codegen.rs b/hyperstack-macros/src/idl_codegen.rs index cc6abba7..1eea50e6 100644 --- a/hyperstack-macros/src/idl_codegen.rs +++ b/hyperstack-macros/src/idl_codegen.rs @@ -107,6 +107,7 @@ pub fn generate_sdk_types(idl: &IdlSpec, module_name: &str) -> TokenStream { let account_types = generate_account_types(&idl.accounts, &idl.types, &account_names); let instruction_types = generate_instruction_types(&idl.instructions, &idl.types, &account_names); + let event_types = generate_event_types(&idl.events, &idl.types, &account_names); let custom_types = generate_custom_types(&idl.types, &account_names); let module_ident = format_ident!("{}", module_name); @@ -130,10 +131,32 @@ pub fn generate_sdk_types(idl: &IdlSpec, module_name: &str) -> TokenStream { use super::types::*; #instruction_types } + + pub mod events { + use super::*; + use super::types::*; + #event_types + } } } } +/// Maximum array size that serde's derive macro supports natively. +/// Arrays larger than this need a custom serde helper. +const SERDE_MAX_ARRAY_SIZE: u32 = 32; + +/// Check if a type is a large array (> 32 elements) that needs special serde handling. +fn is_large_array(idl_type: &IdlType) -> bool { + if let IdlType::Array(arr) = idl_type { + if arr.array.len() == 2 { + if let IdlTypeArrayElement::Size(size) = &arr.array[1] { + return *size > SERDE_MAX_ARRAY_SIZE; + } + } + } + false +} + fn generate_struct_fields( fields: &[IdlField], use_bytemuck: bool, @@ -149,7 +172,16 @@ fn generate_struct_fields( let field_name = format_ident!("{}", to_snake_case(&field.name)); let field_type = type_to_token_stream_in_module(&field.type_, account_names, in_accounts_module); - quote! { pub #field_name: #field_type } + + // Add serde helper attribute for large arrays + if is_large_array(&field.type_) { + quote! { + #[serde(with = "hyperstack::runtime::serde_helpers::big_array")] + pub #field_name: #field_type + } + } else { + quote! { pub #field_name: #field_type } + } } }) .collect() @@ -249,14 +281,41 @@ fn generate_json_value_for_type( } fn generate_struct_to_json_method(fields: &[IdlField], use_bytemuck: bool) -> TokenStream { + generate_struct_to_json_method_inner(fields, use_bytemuck, false) +} + +fn generate_struct_to_json_method_packed(fields: &[IdlField], use_bytemuck: bool) -> TokenStream { + generate_struct_to_json_method_inner(fields, use_bytemuck, true) +} + +fn generate_struct_to_json_method_inner( + fields: &[IdlField], + use_bytemuck: bool, + is_packed: bool, +) -> TokenStream { let field_inserts = fields.iter().map(|field| { let field_ident = format_ident!("{}", to_snake_case(&field.name)); let field_name = field_ident.to_string(); - let field_value = - generate_json_value_for_type(&field.type_, quote! { self.#field_ident }, use_bytemuck); - quote! { - object.insert(#field_name.to_string(), #field_value); + if is_packed { + // For packed structs, copy field to a local variable to avoid + // creating unaligned references (which is UB in Rust). + let local_var = format_ident!("_packed_{}", to_snake_case(&field.name)); + let field_value = + generate_json_value_for_type(&field.type_, quote! { #local_var }, use_bytemuck); + quote! { + let #local_var = { self.#field_ident }; + object.insert(#field_name.to_string(), #field_value); + } + } else { + let field_value = generate_json_value_for_type( + &field.type_, + quote! { self.#field_ident }, + use_bytemuck, + ); + quote! { + object.insert(#field_name.to_string(), #field_value); + } } }); @@ -308,6 +367,14 @@ fn generate_account_type( let use_bytemuck = is_bytemuck_serialization(serialization); let use_unsafe = is_bytemuck_unsafe(serialization); + // Check if the type definition specifies packed repr + let is_packed = types + .iter() + .find(|t| t.name == account.name) + .and_then(|t| t.repr.as_ref()) + .and_then(|r| r.packed) + .unwrap_or(false); + let idl_fields = if let Some(type_def) = &account.type_def { match type_def { IdlTypeDefKind::Struct { fields, .. } => fields.clone(), @@ -323,7 +390,11 @@ fn generate_account_type( }; let fields = generate_struct_fields(&idl_fields, use_bytemuck, account_names, true); - let to_json_method = generate_struct_to_json_method(&idl_fields, use_bytemuck); + let to_json_method = if is_packed { + generate_struct_to_json_method_packed(&idl_fields, use_bytemuck) + } else { + generate_struct_to_json_method(&idl_fields, use_bytemuck) + }; let discriminator = account.get_discriminator(); let disc_array = quote! { [#(#discriminator),*] }; @@ -355,20 +426,36 @@ fn generate_account_type( if use_unsafe { // BytemuckUnsafe: use unsafe impl to bypass padding checks, // matching how the on-chain program was compiled. - quote! { - #[derive(Debug, Copy, Clone)] - #[repr(C)] - pub struct #name { - #(#fields),* + if is_packed { + quote! { + #[derive(Debug, Copy, Clone)] + #[repr(C, packed)] + pub struct #name { + #(#fields),* + } + + unsafe impl hyperstack::runtime::bytemuck::Zeroable for #name {} + unsafe impl hyperstack::runtime::bytemuck::Pod for #name {} + + #bytemuck_try_from } + } else { + quote! { + #[derive(Debug, Copy, Clone)] + #[repr(C)] + pub struct #name { + #(#fields),* + } - unsafe impl hyperstack::runtime::bytemuck::Zeroable for #name {} - unsafe impl hyperstack::runtime::bytemuck::Pod for #name {} + unsafe impl hyperstack::runtime::bytemuck::Zeroable for #name {} + unsafe impl hyperstack::runtime::bytemuck::Pod for #name {} - #bytemuck_try_from + #bytemuck_try_from + } } } else { // Bytemuck (safe): use derive macros which validate no padding at compile time. + // Note: packed structs cannot use derive Pod, so they must use unsafe impl above. quote! { #[derive(Debug, Copy, Clone, hyperstack::runtime::bytemuck::Pod, hyperstack::runtime::bytemuck::Zeroable)] #[bytemuck(crate = "hyperstack::runtime::bytemuck")] @@ -467,6 +554,67 @@ fn generate_instruction_type( } } +fn generate_event_types( + events: &[IdlEvent], + types: &[IdlTypeDef], + account_names: &HashSet, +) -> TokenStream { + let event_structs = events + .iter() + .map(|event| generate_event_type(event, types, account_names)); + + quote! { + #(#event_structs)* + } +} + +fn generate_event_type( + event: &IdlEvent, + types: &[IdlTypeDef], + account_names: &HashSet, +) -> TokenStream { + let name = format_ident!("{}", event.name); + + let discriminator = event.get_discriminator(); + let disc_array = quote! { [#(#discriminator),*] }; + + // Event fields come from the matching type definition in `types` + let idl_fields: Vec = types + .iter() + .find(|t| t.name == event.name) + .and_then(|t| match &t.type_def { + IdlTypeDefKind::Struct { fields, .. } => Some(fields.clone()), + _ => None, + }) + .unwrap_or_default(); + + let fields = generate_struct_fields(&idl_fields, false, account_names, false); + let to_json_method = generate_struct_to_json_method(&idl_fields, false); + + quote! { + #[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] + pub struct #name { + #(#fields),* + } + + impl #name { + pub const DISCRIMINATOR: [u8; 8] = #disc_array; + + /// Decode a CPI event from raw instruction data. + /// Anchor CPI events: 8-byte discriminator followed by Borsh-encoded payload. + pub fn try_from_bytes(data: &[u8]) -> Result> { + if data.len() < 8 { + return Err("Data too short for event discriminator".into()); + } + let mut reader = &data[8..]; + borsh::BorshDeserialize::deserialize_reader(&mut reader).map_err(|e| e.into()) + } + + #to_json_method + } + } +} + fn generate_custom_types(types: &[IdlTypeDef], account_names: &HashSet) -> TokenStream { let type_defs = types.iter().map(|t| generate_custom_type(t, account_names)); @@ -479,17 +627,31 @@ fn generate_custom_type(type_def: &IdlTypeDef, account_names: &HashSet) let name = format_ident!("{}", type_def.name); let use_bytemuck = is_bytemuck_serialization(&type_def.serialization); let use_unsafe = is_bytemuck_unsafe(&type_def.serialization); + let is_packed = type_def + .repr + .as_ref() + .and_then(|r| r.packed) + .unwrap_or(false); match &type_def.type_def { IdlTypeDefKind::Struct { kind: _, fields } => { let struct_fields = generate_struct_fields(fields, use_bytemuck, account_names, false); - let to_json_method = generate_struct_to_json_method(fields, use_bytemuck); + let to_json_method = if is_packed { + generate_struct_to_json_method_packed(fields, use_bytemuck) + } else { + generate_struct_to_json_method(fields, use_bytemuck) + }; if use_bytemuck { if use_unsafe { + let repr_attr = if is_packed { + quote! { #[repr(C, packed)] } + } else { + quote! { #[repr(C)] } + }; quote! { #[derive(Debug, Copy, Clone)] - #[repr(C)] + #repr_attr pub struct #name { #(#struct_fields),* } @@ -921,4 +1083,158 @@ mod tests { "bytemuckunsafe should still use pod_read_unaligned for deserialization" ); } + + fn large_array_borsh_idl() -> IdlSpec { + let json = r#"{ + "address": "TestLargeArrayProgram111111111111111111111", + "metadata": { + "name": "test_large_array", + "version": "0.1.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "LargeArrayAccount", + "discriminator": [1, 2, 3, 4, 5, 6, 7, 8] + }, + { + "name": "SmallArrayAccount", + "discriminator": [10, 20, 30, 40, 50, 60, 70, 80] + } + ], + "types": [ + { + "name": "LargeArrayAccount", + "type": { + "kind": "struct", + "fields": [ + { "name": "owner", "type": "pubkey" }, + { "name": "large_data", "type": { "array": ["u64", 70] } }, + { "name": "small_data", "type": { "array": ["u8", 32] } } + ] + } + }, + { + "name": "SmallArrayAccount", + "type": { + "kind": "struct", + "fields": [ + { "name": "owner", "type": "pubkey" }, + { "name": "data", "type": { "array": ["u8", 32] } } + ] + } + } + ], + "events": [], + "errors": [] + }"#; + parse_idl_content(json).expect("test IDL should parse") + } + + #[test] + fn test_large_array_gets_serde_with_attribute() { + let idl = large_array_borsh_idl(); + let output = generate_sdk_types(&idl, "generated_sdk"); + let code = output.to_string(); + + // Large array (70 elements) should have serde(with = ...) attribute + // The generated code: serde_helpers::big_array (no spaces around ::) + assert!( + code.contains("serde_helpers::big_array"), + "large array should have serde(with) attribute for big_array helper, got: {}", + code + ); + + // Verify the attribute is before large_data field + let parts: Vec<&str> = code.split("pub large_data").collect(); + assert!(parts.len() > 1, "large_data field should exist"); + let before = &parts[0][parts[0].len().saturating_sub(150)..]; + assert!( + before.contains("big_array"), + "serde(with = big_array) should appear before large_data field, got context: {}", + before + ); + } + + #[test] + fn test_small_array_no_serde_with_attribute() { + let idl = large_array_borsh_idl(); + let output = generate_sdk_types(&idl, "generated_sdk"); + let code = output.to_string(); + + // Small array (32 elements) should NOT have serde(with = ...) immediately before it + // We need to check the immediate predecessor of small_data, not the whole preceding code + // The pattern should be: pub large_data : [u64 ; 70] , pub small_data + // NOT: # [serde (...)] pub small_data + + // Check that small_data field exists + assert!( + code.contains("pub small_data"), + "small_data field should exist" + ); + assert!( + code.contains("pub data : [u8 ; 32]"), + "SmallArrayAccount.data field should exist" + ); + + // Verify the SmallArrayAccount doesn't have big_array in its struct definition + // Find just the SmallArrayAccount struct (second occurrence in types and accounts) + let small_account_section = code + .split("pub struct SmallArrayAccount") + .nth(1) + .expect("SmallArrayAccount should exist") + .split("impl SmallArrayAccount") + .next() + .expect("impl block should exist"); + + assert!( + !small_account_section.contains("big_array"), + "SmallArrayAccount should NOT have big_array attribute, got: {}", + small_account_section + ); + } + + #[test] + fn test_is_large_array_detection() { + // Test array > 32 + let large = IdlType::Array(IdlTypeArray { + array: vec![ + IdlTypeArrayElement::Type("u64".to_string()), + IdlTypeArrayElement::Size(70), + ], + }); + assert!(is_large_array(&large), "70-element array should be large"); + + // Test array == 32 (boundary) + let boundary = IdlType::Array(IdlTypeArray { + array: vec![ + IdlTypeArrayElement::Type("u8".to_string()), + IdlTypeArrayElement::Size(32), + ], + }); + assert!( + !is_large_array(&boundary), + "32-element array should NOT be large" + ); + + // Test array < 32 + let small = IdlType::Array(IdlTypeArray { + array: vec![ + IdlTypeArrayElement::Type("u8".to_string()), + IdlTypeArrayElement::Size(16), + ], + }); + assert!( + !is_large_array(&small), + "16-element array should NOT be large" + ); + + // Test non-array type + let not_array = IdlType::Simple("u64".to_string()); + assert!( + !is_large_array(¬_array), + "non-array type should NOT be large" + ); + } } diff --git a/stacks/ore/.hyperstack/OreStream.stack.json b/stacks/ore/.hyperstack/OreStream.stack.json index 4c5d3cc8..b6461063 100644 --- a/stacks/ore/.hyperstack/OreStream.stack.json +++ b/stacks/ore/.hyperstack/OreStream.stack.json @@ -3809,14 +3809,14 @@ "pda_field": { "segments": [ "accounts", - "entropyVar" + "treasury" ], "offsets": null }, "seed_field": { "segments": [ "accounts", - "round" + "roundNext" ], "offsets": null }, @@ -3828,14 +3828,14 @@ "pda_field": { "segments": [ "accounts", - "treasury" + "entropyVar" ], "offsets": null }, "seed_field": { "segments": [ "accounts", - "roundNext" + "round" ], "offsets": null }, @@ -5236,10 +5236,10 @@ "lookup_name": "default_pda_lookup" } } - ], - "lookup_by": null + } } ], + "instruction_hooks": [], "resolver_specs": [], "computed_fields": [ "state.motherlode", @@ -6796,10 +6796,10 @@ "lookup_name": "default_pda_lookup" } } - ], - "lookup_by": null + } } ], + "instruction_hooks": [], "resolver_specs": [], "computed_fields": [], "computed_field_specs": [], @@ -8912,4 +8912,4 @@ } ], "content_hash": "e3f5ab05df7fb576313c02f6f942748f9ef597a1df30de66fa7a24a6cfe39f25" -} \ No newline at end of file +} diff --git a/stacks/ore/Cargo.lock b/stacks/ore/Cargo.lock index e513f307..6f8e1505 100644 --- a/stacks/ore/Cargo.lock +++ b/stacks/ore/Cargo.lock @@ -1159,6 +1159,16 @@ dependencies = [ "yellowstone-vixen-yellowstone-grpc-source", ] +[[package]] +name = "hyperstack-idl" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "strsim", +] + [[package]] name = "hyperstack-interpreter" version = "0.5.3" @@ -1167,6 +1177,7 @@ dependencies = [ "dashmap", "futures", "hex", + "hyperstack-idl", "hyperstack-macros", "lru", "prost 0.13.5", @@ -1187,6 +1198,7 @@ version = "0.5.3" dependencies = [ "bs58", "hex", + "hyperstack-idl", "proc-macro2", "quote", "serde", From cd856fa19606481e3f6775d8a8e591d4782f7913 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:47:07 +0000 Subject: [PATCH 04/23] feat(macros): add CPI event support with camelCase field handling --- hyperstack-macros/src/ast/writer.rs | 36 +++++- hyperstack-macros/src/codegen/core.rs | 89 +++++++++----- hyperstack-macros/src/event_type_helpers.rs | 20 +++ hyperstack-macros/src/idl_parser_gen.rs | 128 ++++++++++++++++++-- 4 files changed, 227 insertions(+), 46 deletions(-) diff --git a/hyperstack-macros/src/ast/writer.rs b/hyperstack-macros/src/ast/writer.rs index 38b4ed39..9aeb22f3 100644 --- a/hyperstack-macros/src/ast/writer.rs +++ b/hyperstack-macros/src/ast/writer.rs @@ -357,6 +357,9 @@ pub fn build_handlers_from_sources( for ((source_type, join_key), mappings) in &sources_by_type_and_join { let account_type = source_type.split("::").last().unwrap_or(source_type); let is_instruction = mappings.iter().any(|m| m.is_instruction); + // CPI events are sourced from `::events::` submodule paths. + // All event fields live under "data.*" (no "accounts.*" section). + let is_cpi_event = source_type.contains("::events::"); // Skip if this is an event-derived mapping if is_instruction @@ -404,7 +407,14 @@ pub fn build_handlers_from_sources( MappingSource::AsCapture { field_transforms } } else { - let field_path = if is_instruction { + let field_path = if is_cpi_event { + // CPI events: all fields are under "data" + if mapping.source_field_name.is_empty() { + FieldPath::new(&["data"]) + } else { + FieldPath::new(&["data", &mapping.source_field_name]) + } + } else if is_instruction { if mapping.source_field_name.is_empty() { FieldPath::new(&["data"]) } else { @@ -474,7 +484,10 @@ pub fn build_handlers_from_sources( if mapping.is_primary_key { has_primary_key = true; - if is_instruction { + if is_cpi_event { + // CPI event fields are always in "data" + primary_field = Some(format!("data.{}", mapping.source_field_name)); + } else if is_instruction { let prefix = idl .and_then(|idl| { idl.get_instruction_field_prefix( @@ -503,10 +516,17 @@ pub fn build_handlers_from_sources( .find_map(|m| m.lookup_by.as_ref()) .map(|fs| { // FieldSpec has explicit_location which tells us if it's accounts:: or data:: + // For CPI events, all fields (including identifiers) are under "data". let prefix = match &fs.explicit_location { Some(parse::FieldLocation::Account) => "accounts", Some(parse::FieldLocation::InstructionArg) => "data", - None => "accounts", // Default to accounts for compatibility + None => { + if is_cpi_event { + "data" // CPI event fields are always in "data" + } else { + "accounts" // Default to accounts for instruction compatibility + } + } }; format!("{}.{}", prefix, fs.ident) }); @@ -544,7 +564,15 @@ pub fn build_handlers_from_sources( } }; - let type_suffix = if is_instruction { "IxState" } else { "State" }; + // CPI events (from `::events::`) use "CpiEvent" suffix; instructions use "IxState" + let is_cpi_event_for_type = source_type.contains("::events::"); + let type_suffix = if is_cpi_event_for_type { + "CpiEvent" + } else if is_instruction { + "IxState" + } else { + "State" + }; let serialization = if is_instruction { None } else { diff --git a/hyperstack-macros/src/codegen/core.rs b/hyperstack-macros/src/codegen/core.rs index e41f116e..37672961 100644 --- a/hyperstack-macros/src/codegen/core.rs +++ b/hyperstack-macros/src/codegen/core.rs @@ -10,48 +10,71 @@ pub fn generate_hook_actions( actions: &[HookAction], _lookup_by: &Option, ) -> TokenStream { - let action_code: Vec = actions.iter().map(|action| { - match action { - HookAction::RegisterPdaMapping { pda_field, seed_field, lookup_name: _ } => { - let pda_field_str = pda_field.segments.last().cloned().unwrap_or_default(); - let seed_field_str = seed_field.segments.last().cloned().unwrap_or_default(); - - quote! { - if let (Some(pda), Some(seed)) = (ctx.account(#pda_field_str), ctx.account(#seed_field_str)) { - ctx.register_pda_reverse_lookup(&pda, &seed); + let action_code: Vec = actions + .iter() + .map(|action| { + match action { + HookAction::RegisterPdaMapping { + pda_field, + seed_field, + lookup_name: _, + } => { + let pda_raw = pda_field.segments.last().cloned().unwrap_or_default(); + let seed_raw = seed_field.segments.last().cloned().unwrap_or_default(); + let pda_camel = crate::event_type_helpers::snake_to_lower_camel(&pda_raw); + let seed_camel = crate::event_type_helpers::snake_to_lower_camel(&seed_raw); + + // IDL account names can be camelCase (e.g. Pumpfun: "bondingCurve") or + // snake_case (e.g. Raydium: "pool_state"). The register_from attribute + // uses Rust field names (always snake_case), so try both the camelCase + // conversion and the raw snake_case name to match the IDL. + quote! { + let pda_val = ctx.account(#pda_camel).or_else(|| ctx.account(#pda_raw)); + let seed_val = ctx.account(#seed_camel).or_else(|| ctx.account(#seed_raw)); + if let (Some(pda), Some(seed)) = (pda_val, seed_val) { + ctx.register_pda_reverse_lookup(&pda, &seed); + } } } - } - HookAction::SetField { target_field, source, condition } => { - let set_code = generate_set_field_code(target_field, source); - if let Some(cond) = condition { - let cond_code = generate_condition_code(cond); - quote! { - if #cond_code { - #set_code + HookAction::SetField { + target_field, + source, + condition, + } => { + let set_code = generate_set_field_code(target_field, source); + if let Some(cond) = condition { + let cond_code = generate_condition_code(cond); + quote! { + if #cond_code { + #set_code + } } + } else { + set_code } - } else { - set_code } - } - HookAction::IncrementField { target_field, increment_by, condition } => { - let increment_code = quote! { - ctx.increment(#target_field, #increment_by); - }; - if let Some(cond) = condition { - let cond_code = generate_condition_code(cond); - quote! { - if #cond_code { - #increment_code + HookAction::IncrementField { + target_field, + increment_by, + condition, + } => { + let increment_code = quote! { + ctx.increment(#target_field, #increment_by); + }; + if let Some(cond) = condition { + let cond_code = generate_condition_code(cond); + quote! { + if #cond_code { + #increment_code + } } + } else { + increment_code } - } else { - increment_code } } - } - }).collect(); + }) + .collect(); quote! { #(#action_code)* diff --git a/hyperstack-macros/src/event_type_helpers.rs b/hyperstack-macros/src/event_type_helpers.rs index 5fdd56c0..e2efb16f 100644 --- a/hyperstack-macros/src/event_type_helpers.rs +++ b/hyperstack-macros/src/event_type_helpers.rs @@ -56,3 +56,23 @@ pub fn program_name_for_type<'a>(type_str: &str, idls: IdlLookup<'a>) -> Option< pub fn program_name_from_sdk_prefix(sdk_module: &str) -> &str { sdk_module.strip_suffix("_sdk").unwrap_or(sdk_module) } + +/// Convert a snake_case identifier to lowerCamelCase. +/// "bonding_curve" -> "bondingCurve" +/// "mint" -> "mint" (single word unchanged) +/// "associated_bonding_curve" -> "associatedBondingCurve" +pub fn snake_to_lower_camel(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut capitalize_next = false; + for ch in s.chars() { + if ch == '_' { + capitalize_next = true; + } else if capitalize_next { + result.extend(ch.to_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result +} diff --git a/hyperstack-macros/src/idl_parser_gen.rs b/hyperstack-macros/src/idl_parser_gen.rs index ed8c407e..f811be2c 100644 --- a/hyperstack-macros/src/idl_parser_gen.rs +++ b/hyperstack-macros/src/idl_parser_gen.rs @@ -190,11 +190,19 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream let program_name = idl.get_name(); let ix_enum_name = format_ident!("{}Instruction", to_pascal_case(program_name)); + // Instruction variants let ix_enum_variants = idl.instructions.iter().map(|ix| { let variant_name = format_ident!("{}", to_pascal_case(&ix.name)); quote! { #variant_name(instructions::#variant_name) } }); + // Event variants: prefixed with "Event_" to avoid name collisions with instruction variants + let event_enum_variants = idl.events.iter().map(|ev| { + let variant_name = format_ident!("Event_{}", ev.name); + let event_type = format_ident!("{}", ev.name); + quote! { #variant_name(events::#event_type) } + }); + let uses_steel_discriminant = idl .instructions .iter() @@ -253,7 +261,51 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream } }); - let convert_to_json_arms = idl.instructions.iter().map(|ix| { + // Anchor CPI event wire format: + // bytes 0- 7: SHA256("anchor:event")[..8] = [29, 154, 203, 81, 46, 165, 69, 228] + // bytes 8-15: SHA256("event:")[..8] = the event-specific discriminator + // bytes 16+ : Borsh-encoded event payload + // + // So we match a 16-byte prefix (anchor tag + event disc) and pass &data[8..] to + // try_from_bytes(), which skips its own 8-byte discriminator and reads from byte 16. + // SHA256("anchor:event")[..8] — the fixed tag prepended to all Anchor CPI event instructions + let anchor_event_tag: [u8; 8] = [228u8, 69, 165, 46, 81, 203, 154, 29]; + let at0 = anchor_event_tag[0]; + let at1 = anchor_event_tag[1]; + let at2 = anchor_event_tag[2]; + let at3 = anchor_event_tag[3]; + let at4 = anchor_event_tag[4]; + let at5 = anchor_event_tag[5]; + let at6 = anchor_event_tag[6]; + let at7 = anchor_event_tag[7]; + + let event_unpack_arms = idl.events.iter().map(|ev| { + let variant_name = format_ident!("Event_{}", ev.name); + let event_type = format_ident!("{}", ev.name); + let discriminator = ev.get_discriminator(); + let disc_bytes: Vec = discriminator.iter().take(8).copied().collect(); + let d0 = disc_bytes.first().copied().unwrap_or(0); + let d1 = disc_bytes.get(1).copied().unwrap_or(0); + let d2 = disc_bytes.get(2).copied().unwrap_or(0); + let d3 = disc_bytes.get(3).copied().unwrap_or(0); + let d4 = disc_bytes.get(4).copied().unwrap_or(0); + let d5 = disc_bytes.get(5).copied().unwrap_or(0); + let d6 = disc_bytes.get(6).copied().unwrap_or(0); + let d7 = disc_bytes.get(7).copied().unwrap_or(0); + + // Match the full 16-byte prefix: anchor:event tag + event-specific discriminator. + // Pass &data[8..] to try_from_bytes so it can skip the 8-byte event discriminator + // and deserialize the payload starting at byte 16. + quote! { + [#at0, #at1, #at2, #at3, #at4, #at5, #at6, #at7, + #d0, #d1, #d2, #d3, #d4, #d5, #d6, #d7, ..] => { + let event_data = events::#event_type::try_from_bytes(&data[8..])?; + Ok(#ix_enum_name::#variant_name(event_data)) + } + } + }); + + let convert_to_json_arms_ix = idl.instructions.iter().map(|ix| { let variant_name = format_ident!("{}", to_pascal_case(&ix.name)); let type_name = format!("{}::{}", program_name, to_pascal_case(&ix.name)); @@ -267,7 +319,22 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream } }); - let type_name_arms = idl.instructions.iter().map(|ix| { + let convert_to_json_arms_ev = idl.events.iter().map(|ev| { + let variant_name = format_ident!("Event_{}", ev.name); + // Use CpiEvent suffix to distinguish from instructions + let type_name = format!("{}::{}CpiEvent", program_name, ev.name); + + quote! { + #ix_enum_name::#variant_name(data) => { + hyperstack::runtime::serde_json::json!({ + "type": #type_name, + "data": data.to_json_value() + }) + } + } + }); + + let type_name_arms_ix = idl.instructions.iter().map(|ix| { let variant_name = format_ident!("{}", to_pascal_case(&ix.name)); let type_name = format!("{}::{}IxState", program_name, to_pascal_case(&ix.name)); @@ -276,7 +343,19 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream } }); - let to_value_arms = idl.instructions.iter().map(|ix| { + let type_name_arms_ev = idl.events.iter().map(|ev| { + let variant_name = format_ident!("Event_{}", ev.name); + // Use CpiEvent suffix to distinguish from instructions (IxState suffix). + // The AST writer must generate the same suffix when it sees a type from + // the `events` submodule (e.g., generated_sdk::events::Swap). + let type_name = format!("{}::{}CpiEvent", program_name, ev.name); + + quote! { + #ix_enum_name::#variant_name(_) => #type_name + } + }); + + let to_value_arms_ix = idl.instructions.iter().map(|ix| { let variant_name = format_ident!("{}", to_pascal_case(&ix.name)); quote! { @@ -288,6 +367,18 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream } }); + let to_value_arms_ev = idl.events.iter().map(|ev| { + let variant_name = format_ident!("Event_{}", ev.name); + + quote! { + #ix_enum_name::#variant_name(data) => { + hyperstack::runtime::serde_json::json!({ + "data": data.to_json_value() + }) + } + } + }); + let to_value_with_accounts_arms = idl.instructions.iter().map(|ix| { let variant_name = format_ident!("{}", to_pascal_case(&ix.name)); let ix_name = &ix.name; @@ -332,10 +423,24 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream } }); + // CPI events have no accounts — expose event fields directly under "data" + let to_value_with_accounts_arms_ev = idl.events.iter().map(|ev| { + let variant_name = format_ident!("Event_{}", ev.name); + + quote! { + #ix_enum_name::#variant_name(data) => { + hyperstack::runtime::serde_json::json!({ + "data": data.to_json_value() + }) + } + } + }); + quote! { #[derive(Debug)] pub enum #ix_enum_name { - #(#ix_enum_variants),* + #(#ix_enum_variants,)* + #(#event_enum_variants),* } impl #ix_enum_name { @@ -345,7 +450,8 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream } match data { - #(#unpack_arms),* + #(#unpack_arms,)* + #(#event_unpack_arms,)* _ => { let disc_preview: Vec = data.iter().take(8).copied().collect(); Err(format!("Unknown instruction discriminator: {:?}", disc_preview).into()) @@ -355,25 +461,29 @@ fn generate_instruction_parser(idl: &IdlSpec, _program_id: &str) -> TokenStream pub fn to_json(&self) -> hyperstack::runtime::serde_json::Value { match self { - #(#convert_to_json_arms),* + #(#convert_to_json_arms_ix,)* + #(#convert_to_json_arms_ev),* } } pub fn event_type(&self) -> &'static str { match self { - #(#type_name_arms),* + #(#type_name_arms_ix,)* + #(#type_name_arms_ev),* } } pub fn to_value(&self) -> hyperstack::runtime::serde_json::Value { match self { - #(#to_value_arms),* + #(#to_value_arms_ix,)* + #(#to_value_arms_ev),* } } pub fn to_value_with_accounts(&self, accounts: &[hyperstack::runtime::yellowstone_vixen_core::KeyBytes<32>]) -> hyperstack::runtime::serde_json::Value { match self { - #(#to_value_with_accounts_arms),* + #(#to_value_with_accounts_arms,)* + #(#to_value_with_accounts_arms_ev),* } } } From b337d09c1b45bd32604d65e05c96d0dc5dc8ea7a Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:47:19 +0000 Subject: [PATCH 05/23] feat(macros): support packed structs, large arrays, and stream spec improvements --- hyperstack-macros/src/idl_vixen_gen.rs | 17 ++--- hyperstack-macros/src/parse/attributes.rs | 19 +++-- hyperstack-macros/src/parse/idl.rs | 4 +- .../src/stream_spec/ast_writer.rs | 69 +++++++++++++++---- hyperstack-macros/src/stream_spec/entity.rs | 68 +++++++++++------- hyperstack-macros/src/stream_spec/handlers.rs | 20 +++--- hyperstack-macros/src/stream_spec/idl_spec.rs | 54 ++++++++++----- hyperstack-macros/src/stream_spec/sections.rs | 69 ++++++++++++------- 8 files changed, 212 insertions(+), 108 deletions(-) diff --git a/hyperstack-macros/src/idl_vixen_gen.rs b/hyperstack-macros/src/idl_vixen_gen.rs index b0dabd36..0e3737c5 100644 --- a/hyperstack-macros/src/idl_vixen_gen.rs +++ b/hyperstack-macros/src/idl_vixen_gen.rs @@ -21,19 +21,10 @@ fn path_to_event_type(path: &Path, is_instruction: bool, default_program_name: & .unwrap_or_default(); let suffix = if is_instruction { "IxState" } else { "State" }; - // Derive program name from path's first segment if it ends with _sdk - let program_name = if path.segments.len() >= 2 { - let first_seg = path.segments.first().unwrap().ident.to_string(); - if let Some(stripped) = first_seg.strip_suffix("_sdk") { - stripped.to_string() - } else { - default_program_name.to_string() - } - } else { - default_program_name.to_string() - }; - - format!("{}::{}{}", program_name, type_name, suffix) + // Always use the program name from the IDL spec, not the generated module path. + // The parser's event_type() uses the IDL program name (e.g., "pump"), + // so hook match arms must use the same prefix to match at runtime. + format!("{}::{}{}", default_program_name, type_name, suffix) } pub fn generate_resolver_registries( diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 80545adc..4a605a12 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -77,6 +77,9 @@ pub struct CaptureAttribute { pub from_account: Option, // Explicit source via `from = ...` pub inferred_account: Option, // Inferred from field type + // Single field extraction from account (e.g., field = token_mint_0) + pub field: Option, + // Field transformations pub field_transforms: HashMap, // Map field name to transformation @@ -674,6 +677,7 @@ pub fn parse_event_attribute( // Parse args for #[snapshot] attribute struct SnapshotAttributeArgs { from: Option, + field: Option, strategy: Option, rename: Option, join_on: Option, @@ -685,6 +689,7 @@ struct SnapshotAttributeArgs { impl Parse for SnapshotAttributeArgs { fn parse(input: ParseStream) -> syn::Result { let mut from = None; + let mut field = None; let mut strategy = None; let mut rename = None; let mut join_on = None; @@ -700,6 +705,8 @@ impl Parse for SnapshotAttributeArgs { if ident_str == "from" { from = Some(input.parse()?); + } else if ident_str == "field" { + field = Some(input.parse()?); } else if ident_str == "strategy" { strategy = Some(input.parse()?); } else if ident_str == "rename" { @@ -762,6 +769,7 @@ impl Parse for SnapshotAttributeArgs { Ok(SnapshotAttributeArgs { from, + field, strategy, rename, join_on, @@ -804,6 +812,7 @@ pub fn parse_snapshot_attribute( Ok(Some(CaptureAttribute { from_account: args.from, inferred_account: None, // Will be filled in later from field type + field: args.field, field_transforms: args.transforms.into_iter().collect(), strategy, target_field_name: target_name, @@ -1087,10 +1096,12 @@ impl Parse for ResolveAttributeArgs { let method_ident: syn::Ident = input.parse()?; match method_ident.to_string().to_lowercase().as_str() { "get" | "post" => method = Some(method_ident), - _ => return Err(syn::Error::new( - method_ident.span(), - "Invalid HTTP method. Only 'GET' or 'POST' are supported.", - )), + _ => { + return Err(syn::Error::new( + method_ident.span(), + "Invalid HTTP method. Only 'GET' or 'POST' are supported.", + )) + } } } else if ident_str == "extract" { let lit: syn::LitStr = input.parse()?; diff --git a/hyperstack-macros/src/parse/idl.rs b/hyperstack-macros/src/parse/idl.rs index c7c0f3c1..352243d1 100644 --- a/hyperstack-macros/src/parse/idl.rs +++ b/hyperstack-macros/src/parse/idl.rs @@ -51,8 +51,8 @@ pub fn to_rust_type_string(idl_type: &IdlType) -> String { } } - /// Convert an IDL type to a Rust type string for bytemuck (zero-copy) accounts. - pub fn to_rust_type_string_bytemuck(idl_type: &IdlType) -> String { +/// Convert an IDL type to a Rust type string for bytemuck (zero-copy) accounts. +pub fn to_rust_type_string_bytemuck(idl_type: &IdlType) -> String { match idl_type { IdlType::Simple(s) => map_simple_type_bytemuck(s), IdlType::Array(arr) => { diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index 7cb7013e..c24efdc2 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -432,6 +432,9 @@ fn build_source_handler( let idl = find_idl_for_type(source_type, idls); let program_name = program_name_for_type(source_type, idls); let is_instruction = mappings.iter().any(|m| m.is_instruction); + // CPI events are sourced from `::events::` submodule paths (e.g. generated_sdk::events::Swap). + // All event fields are stored under "data.*" (no "accounts.*" section). + let is_cpi_event = source_type.contains("::events::"); // Skip event-derived mappings if is_instruction @@ -478,7 +481,14 @@ fn build_source_handler( MappingSource::AsCapture { field_transforms } } else { - let field_path = if is_instruction { + let field_path = if is_cpi_event { + // CPI events: all fields (including identifiers like lb_pair, from) are under "data" + if mapping.source_field_name.is_empty() { + FieldPath::new(&["data"]) + } else { + FieldPath::new(&["data", &mapping.source_field_name]) + } + } else if is_instruction { if mapping.source_field_name.is_empty() { FieldPath::new(&["data"]) } else { @@ -550,7 +560,10 @@ fn build_source_handler( if mapping.is_primary_key { has_primary_key = true; - if is_instruction { + if is_cpi_event { + // CPI event fields are always in "data" + primary_field = Some(format!("data.{}", mapping.source_field_name)); + } else if is_instruction { let prefix = idl .and_then(|idl| { idl.get_instruction_field_prefix(account_type, &mapping.source_field_name) @@ -576,10 +589,17 @@ fn build_source_handler( .find_map(|m| m.lookup_by.as_ref()) .map(|fs| { // FieldSpec has explicit_location which tells us if it's accounts:: or data:: + // For CPI events, all fields (including identifiers) are under "data". let prefix = match &fs.explicit_location { Some(parse::FieldLocation::Account) => "accounts", Some(parse::FieldLocation::InstructionArg) => "data", - None => "accounts", // Default to accounts for compatibility + None => { + if is_cpi_event { + "data" // CPI event fields are always in "data" + } else { + "accounts" // Default to accounts for instruction compatibility + } + } }; format!("{}.{}", prefix, fs.ident) }); @@ -650,7 +670,18 @@ fn build_source_handler( } }; - let type_suffix = if is_instruction { "IxState" } else { "State" }; + // Determine type suffix: + // - CPI events (from `::events::` submodule) use "CpiEvent" + // - Instructions use "IxState" + // - Account state uses "State" + let is_cpi_event = source_type.contains("::events::"); + let type_suffix = if is_cpi_event { + "CpiEvent" + } else if is_instruction { + "IxState" + } else { + "State" + }; let serialization = if is_instruction { None } else { @@ -941,9 +972,10 @@ fn build_resolver_hooks_ast( .filter_map(|instr_path| { idl.and_then(|idl| { let instr_name = instr_path.segments.last()?.ident.to_string(); + let instr_snake = crate::utils::to_snake_case(&instr_name); idl.instructions .iter() - .find(|instr| instr.name.eq_ignore_ascii_case(&instr_name)) + .find(|instr| instr.name == instr_snake) .map(|instr| instr.get_discriminator()) }) }) @@ -1092,10 +1124,13 @@ fn build_instruction_hooks_ast( for (instruction_type, derive_attrs) in sorted_derive_from { let instr_base = instruction_type.split("::").last().unwrap(); let program_name = program_name_for_type(instruction_type, idls); + // CPI events (from `::events::` submodule) use "CpiEvent" suffix; instructions use "IxState" + let is_cpi_event = instruction_type.contains("::events::"); + let type_suffix = if is_cpi_event { "CpiEvent" } else { "IxState" }; let instr_type_state = if let Some(program_name) = program_name { - format!("{}::{}IxState", program_name, instr_base) + format!("{}::{}{}", program_name, instr_base, type_suffix) } else { - format!("{}IxState", instr_base) + format!("{}{}", instr_base, type_suffix) }; for derive_attr in derive_attrs { @@ -1113,9 +1148,14 @@ fn build_instruction_hooks_ast( _ => continue, } } else { - let path_prefix = match &derive_attr.field.explicit_location { - Some(parse::FieldLocation::Account) => "accounts", - Some(parse::FieldLocation::InstructionArg) | None => "data", + let path_prefix = if is_cpi_event { + // CPI event fields are always under "data" (no "accounts" section) + "data" + } else { + match &derive_attr.field.explicit_location { + Some(parse::FieldLocation::Account) => "accounts", + Some(parse::FieldLocation::InstructionArg) | None => "data", + } }; MappingSource::FromSource { @@ -1139,10 +1179,11 @@ fn build_instruction_hooks_ast( condition, }; - let lookup_by = derive_attr - .lookup_by - .as_ref() - .map(|field_spec| FieldPath::new(&["accounts", &field_spec.ident.to_string()])); + // CPI events: lookup_by fields are under "data"; instructions: under "accounts" + let lookup_by_prefix = if is_cpi_event { "data" } else { "accounts" }; + let lookup_by = derive_attr.lookup_by.as_ref().map(|field_spec| { + FieldPath::new(&[lookup_by_prefix, &field_spec.ident.to_string()]) + }); let hook = instruction_hooks_map .entry(instr_type_state.clone()) diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index a396771e..374b07c5 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -18,7 +18,9 @@ use std::collections::{HashMap, HashSet}; use quote::{format_ident, quote}; use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type}; -use crate::ast::{EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig, UrlSource, UrlTemplatePart}; +use crate::ast::{ + EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig, +}; use crate::codegen; use crate::event_type_helpers::IdlLookup; use crate::parse; @@ -62,7 +64,10 @@ pub fn parse_url_template(s: &str) -> Vec { if open > 0 { parts.push(UrlTemplatePart::Literal(rest[..open].to_string())); } - let close = rest[open..].find('}').expect("Unclosed '{' in URL template") + open; + let close = rest[open..] + .find('}') + .expect("Unclosed '{' in URL template") + + open; let field_ref = rest[open + 1..close].trim().to_string(); parts.push(UrlTemplatePart::FieldRef(field_ref)); rest = &rest[close + 1..]; @@ -321,25 +326,34 @@ pub fn process_entity_struct_with_idl( if let Some(acct_path) = account_path { let source_type_str = path_to_string(&acct_path); - // Check if we have field transforms - encode them in source_field_name - // so we can detect and process them differently during code generation - let source_field_marker = if !snapshot_attr.field_transforms.is_empty() { - format!( - "__snapshot_with_transforms:{}", - snapshot_attr - .field_transforms - .iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join(",") - ) - } else { - String::new() - }; + // Determine source field name and whether this is a whole-source capture + // or a single-field extraction based on the `field` parameter + let (source_field_name, is_whole_source) = + if let Some(ref field_ident) = snapshot_attr.field { + // Single field extraction: field = token_mint_0 + (field_ident.to_string(), false) + } else if !snapshot_attr.field_transforms.is_empty() { + // Whole source with transforms + ( + format!( + "__snapshot_with_transforms:{}", + snapshot_attr + .field_transforms + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(",") + ), + true, + ) + } else { + // Whole source capture (no field, no transforms) + (String::new(), true) + }; let map_attr = parse::MapAttribute { source_type_path: acct_path, - source_field_name: source_field_marker, + source_field_name, target_field_name: snapshot_attr.target_field_name.clone(), is_primary_key: false, is_lookup_index: false, @@ -353,7 +367,7 @@ pub fn process_entity_struct_with_idl( transform: None, resolver_transform: None, is_instruction: false, - is_whole_source: true, + is_whole_source, lookup_by: snapshot_attr.lookup_by.clone(), condition: None, when: snapshot_attr.when.clone(), @@ -446,13 +460,16 @@ pub fn process_entity_struct_with_idl( }); // Determine resolver type: URL resolver if url is present, otherwise Token resolver - let resolver = if let Some(url_val) = resolve_attr.url.clone() { - let method = resolve_attr.method.as_deref().map(|m| { - match m.to_lowercase().as_str() { + let resolver = if let Some(url_path) = resolve_attr.url.clone() { + // URL resolver + let method = resolve_attr + .method + .as_deref() + .map(|m| match m.to_lowercase().as_str() { "post" => HttpMethod::Post, _ => HttpMethod::Get, - } - }).unwrap_or(HttpMethod::Get); + }) + .unwrap_or(HttpMethod::Get); let url_source = if resolve_attr.url_is_template { UrlSource::Template(parse_url_template(&url_val)) @@ -471,8 +488,7 @@ pub fn process_entity_struct_with_idl( .unwrap_or_else(|err| panic!("{}", err)) } else { // Token resolver with inferred type - infer_resolver_type(field_type) - .unwrap_or_else(|err| panic!("{}", err)) + infer_resolver_type(field_type).unwrap_or_else(|err| panic!("{}", err)) }; let from = if resolve_attr.url_is_template { diff --git a/hyperstack-macros/src/stream_spec/handlers.rs b/hyperstack-macros/src/stream_spec/handlers.rs index ac699f87..233fcc89 100644 --- a/hyperstack-macros/src/stream_spec/handlers.rs +++ b/hyperstack-macros/src/stream_spec/handlers.rs @@ -534,16 +534,20 @@ pub fn generate_pda_registration_functions( for (i, registration) in pda_registrations.iter().enumerate() { let _instruction_type = ®istration.instruction_path; let fn_name = format_ident!("register_pda_{}", i); - let pda_field = registration.pda_field.ident.to_string(); - let primary_key_field = registration.primary_key_field.ident.to_string(); - - // Note: We do NOT emit #[after_instruction] attribute here because: - // 1. These functions are generated during macro expansion - // 2. The instruction hooks need to be registered in the AST/bytecode system - // 3. These will be called through the bytecode VM's instruction processing + let pda_raw = registration.pda_field.ident.to_string(); + let pk_raw = registration.primary_key_field.ident.to_string(); + let pda_camel = crate::event_type_helpers::snake_to_lower_camel(&pda_raw); + let pk_camel = crate::event_type_helpers::snake_to_lower_camel(&pk_raw); + + // IDL account names can be camelCase (e.g. Pumpfun: "bondingCurve") or + // snake_case (e.g. Raydium: "pool_state"). The register_from attribute + // uses Rust field names (always snake_case), so try both the camelCase + // conversion and the raw snake_case name to match the IDL. functions.push(quote! { pub fn #fn_name(ctx: &mut hyperstack::runtime::hyperstack_interpreter::resolvers::InstructionContext) { - if let (Some(primary_key), Some(pda)) = (ctx.account(#primary_key_field), ctx.account(#pda_field)) { + let pk_val = ctx.account(#pk_camel).or_else(|| ctx.account(#pk_raw)); + let pda_val = ctx.account(#pda_camel).or_else(|| ctx.account(#pda_raw)); + if let (Some(primary_key), Some(pda)) = (pk_val, pda_val) { ctx.register_pda_reverse_lookup(&pda, &primary_key); } } diff --git a/hyperstack-macros/src/stream_spec/idl_spec.rs b/hyperstack-macros/src/stream_spec/idl_spec.rs index e5b5136a..6e2595c6 100644 --- a/hyperstack-macros/src/stream_spec/idl_spec.rs +++ b/hyperstack-macros/src/stream_spec/idl_spec.rs @@ -592,9 +592,12 @@ fn collect_register_from_specs( /// /// This function returns a HashMap where the key is the entity name and the value /// is the list of PDA registrations for that specific entity only. +/// +/// Scans both entity struct fields and section struct fields, since `register_from` +/// attributes are typically defined inside section structs (e.g., `PoolId`, `PositionId`). fn collect_pda_registrations_per_entity( entity_structs: &[syn::ItemStruct], - _section_structs: &HashMap, + section_structs: &HashMap, ) -> HashMap> { let mut per_entity_regs: HashMap> = HashMap::new(); @@ -603,24 +606,41 @@ fn collect_pda_registrations_per_entity( .unwrap_or_else(|| entity_struct.ident.to_string()); let mut entity_regs: Vec = Vec::new(); + // Collect all structs to scan: the entity struct itself plus any section structs it references + let mut structs_to_scan: Vec<&syn::ItemStruct> = vec![entity_struct]; if let syn::Fields::Named(fields) = &entity_struct.fields { for field in &fields.named { - let field_name = field - .ident - .as_ref() - .map(|i| i.to_string()) - .unwrap_or_default(); - for attr in &field.attrs { - if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, &field_name) { - for map_attr in &map_attrs { - if !map_attr.register_from.is_empty() { - for rf in &map_attr.register_from { - entity_regs.push(parse::RegisterPdaAttribute { - instruction_path: rf.instruction_path.clone(), - pda_field: rf.pda_field.clone(), - primary_key_field: rf.primary_key_field.clone(), - lookup_name: "default_pda_lookup".to_string(), - }); + if let syn::Type::Path(type_path) = &field.ty { + if let Some(type_ident) = type_path.path.segments.last() { + let type_name = type_ident.ident.to_string(); + if let Some(section_struct) = section_structs.get(&type_name) { + structs_to_scan.push(section_struct); + } + } + } + } + } + + for scan_struct in &structs_to_scan { + if let syn::Fields::Named(fields) = &scan_struct.fields { + for field in &fields.named { + let field_name = field + .ident + .as_ref() + .map(|i| i.to_string()) + .unwrap_or_default(); + for attr in &field.attrs { + if let Ok(Some(map_attrs)) = parse::parse_map_attribute(attr, &field_name) { + for map_attr in &map_attrs { + if !map_attr.register_from.is_empty() { + for rf in &map_attr.register_from { + entity_regs.push(parse::RegisterPdaAttribute { + instruction_path: rf.instruction_path.clone(), + pda_field: rf.pda_field.clone(), + primary_key_field: rf.primary_key_field.clone(), + lookup_name: "default_pda_lookup".to_string(), + }); + } } } } diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 5bed3ac5..64c49921 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -501,24 +501,34 @@ pub fn process_nested_struct( if let Some(acct_path) = account_path { let source_type_str = path_to_string(&acct_path); - // Check if we have field transforms - encode them in source_field_name - let source_field_marker = if !snapshot_attr.field_transforms.is_empty() { - format!( - "__snapshot_with_transforms:{}", - snapshot_attr - .field_transforms - .iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join(",") - ) - } else { - String::new() - }; + // Determine source field name and whether this is a whole-source capture + // or a single-field extraction based on the `field` parameter + let (source_field_name, is_whole_source) = + if let Some(ref field_ident) = snapshot_attr.field { + // Single field extraction: field = token_mint_0 + (field_ident.to_string(), false) + } else if !snapshot_attr.field_transforms.is_empty() { + // Whole source with transforms + ( + format!( + "__snapshot_with_transforms:{}", + snapshot_attr + .field_transforms + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(",") + ), + true, + ) + } else { + // Whole source capture (no field, no transforms) + (String::new(), true) + }; let map_attr = parse::MapAttribute { source_type_path: acct_path, - source_field_name: source_field_marker, + source_field_name, target_field_name: snapshot_attr.target_field_name.clone(), is_primary_key: false, is_lookup_index: false, @@ -532,7 +542,7 @@ pub fn process_nested_struct( transform: None, resolver_transform: None, is_instruction: false, - is_whole_source: true, + is_whole_source, lookup_by: snapshot_attr.lookup_by.clone(), condition: None, when: snapshot_attr.when.clone(), @@ -633,18 +643,29 @@ pub fn process_nested_struct( } else if let Ok(Some(resolve_attr)) = parse::parse_resolve_attribute(attr, &field_name.to_string()) { - let resolver = if resolve_attr.url.is_some() { - let method = resolve_attr.method.as_deref().map(|m| { - match m.to_lowercase().as_str() { + // Determine resolver type: URL resolver if url is present, otherwise Token resolver + let qualified_url = resolve_attr.url.as_deref().map(|url_path_raw| { + if url_path_raw.contains('.') { + url_path_raw.to_string() + } else { + format!("{}.{}", section_name, url_path_raw) + } + }); + + let resolver = if let Some(ref url_path) = qualified_url { + let method = resolve_attr + .method + .as_deref() + .map(|m| match m.to_lowercase().as_str() { "post" => crate::ast::HttpMethod::Post, _ => crate::ast::HttpMethod::Get, - } - }).unwrap_or(crate::ast::HttpMethod::Get); + }) + .unwrap_or(crate::ast::HttpMethod::Get); let url_source = if resolve_attr.url_is_template { - crate::ast::UrlSource::Template( - super::entity::parse_url_template(resolve_attr.url.as_deref().unwrap()) - ) + crate::ast::UrlSource::Template(super::entity::parse_url_template( + resolve_attr.url.as_deref().unwrap(), + )) } else { let url_path_raw = resolve_attr.url.as_deref().unwrap(); let qualified = if url_path_raw.contains('.') { From d8b46ea369ce5669f4cd357e78c83132c758a66d Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:47:25 +0000 Subject: [PATCH 06/23] feat(macros): enhance Vixen runtime with improved tracing and queue handling --- .../src/codegen/vixen_runtime.rs | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/hyperstack-macros/src/codegen/vixen_runtime.rs b/hyperstack-macros/src/codegen/vixen_runtime.rs index 2202b155..be89e092 100644 --- a/hyperstack-macros/src/codegen/vixen_runtime.rs +++ b/hyperstack-macros/src/codegen/vixen_runtime.rs @@ -722,6 +722,12 @@ pub fn generate_vm_handler( } hyperstack::runtime::hyperstack_interpreter::resolvers::KeyResolution::QueueUntil(_discriminators) => { let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + tracing::info!( + event_type = %event_type, + pda = %account_address, + slot = slot, + "QueueUntil: queueing account update for later flush" + ); let _ = vm.queue_account_update( 0, @@ -898,6 +904,11 @@ pub fn generate_vm_handler( // Process pending account updates from instruction hooks if !pending_updates.is_empty() { + tracing::info!( + count = pending_updates.len(), + event_type = %event_type, + "Flushing pending account updates from instruction hooks" + ); for update in pending_updates { let resolved_key = vm.try_pda_reverse_lookup(0, "default_pda_lookup", &update.pda_address); @@ -916,11 +927,23 @@ pub fn generate_vm_handler( match vm.process_event(&bytecode, account_data, &update.account_type, Some(&update_context), None) { Ok(pending_mutations) => { + tracing::info!( + account_type = %update.account_type, + pda = %update.pda_address, + mutations = pending_mutations.len(), + "Reprocessed flushed account update" + ); if let Ok(ref mut mutations) = result { mutations.extend(pending_mutations); } } - Err(_e) => {} + Err(e) => { + tracing::warn!( + account_type = %update.account_type, + error = %e, + "Failed to reprocess flushed account update" + ); + } } } } @@ -1699,7 +1722,6 @@ pub fn generate_account_handler_impl( } hyperstack::runtime::hyperstack_interpreter::resolvers::KeyResolution::QueueUntil(_discriminators) => { let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); - let _ = vm.queue_account_update( 0, hyperstack::runtime::hyperstack_interpreter::QueuedAccountUpdate { @@ -1874,6 +1896,7 @@ pub fn generate_instruction_handler_impl( } let pending_updates = ctx.take_pending_updates(); + let hooks_count = hooks.len(); drop(ctx); @@ -1900,7 +1923,13 @@ pub fn generate_instruction_handler_impl( mutations.extend(pending_mutations); } } - Err(_e) => {} + Err(e) => { + hyperstack::runtime::tracing::warn!( + account_type = %update.account_type, + error = %e, + "Flushed account reprocessing failed" + ); + } } } } From 23503acaa69736d7487d5a9cdd01239d8f86776d Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:47:25 +0000 Subject: [PATCH 07/23] feat(interpreter): various compiler and VM improvements --- interpreter/src/compiler.rs | 12 ++++- interpreter/src/typescript.rs | 85 ++++++++++++++++++++++++++++++++--- interpreter/src/vm.rs | 82 +++++++++++++++++++++++++++++---- 3 files changed, 164 insertions(+), 15 deletions(-) diff --git a/interpreter/src/compiler.rs b/interpreter/src/compiler.rs index 2a38fdc8..65dc1a4e 100644 --- a/interpreter/src/compiler.rs +++ b/interpreter/src/compiler.rs @@ -1230,8 +1230,18 @@ impl TypedCompiler { // which caused the PDA address to be used as the key instead of the round_id. // This resulted in mutations with key = PDA address instead of key = primary_key. - // Use lookup result (may be null). Do not preserve intermediate resolver key. + // First, set key_reg to __resolved_primary_key (may be null). + // When the flush path provides a resolved key via PDA reverse lookup, + // this gives it priority over the LookupIndex result. + // This matches the pattern used by Embedded and Computed strategies. ops.push(OpCode::CopyRegister { + source: resolved_key_reg, + dest: key_reg, + }); + // If key_reg is still null (no resolved key from flush), use the LookupIndex result. + // This preserves the original behavior for first-arrival events where + // __resolved_primary_key is not yet set. + ops.push(OpCode::CopyRegisterIfNull { source: result_reg, dest: key_reg, }); diff --git a/interpreter/src/typescript.rs b/interpreter/src/typescript.rs index 2d345cba..2994c77a 100644 --- a/interpreter/src/typescript.rs +++ b/interpreter/src/typescript.rs @@ -65,6 +65,7 @@ pub struct TypeScriptCompiler { idl: Option, // IDL for enum type generation handlers_json: Option, // Raw handlers for event interface generation views: Vec, // View definitions for derived views + already_emitted_types: HashSet, } impl TypeScriptCompiler { @@ -76,6 +77,7 @@ impl TypeScriptCompiler { idl: None, handlers_json: None, views: Vec::new(), + already_emitted_types: HashSet::new(), } } @@ -99,6 +101,11 @@ impl TypeScriptCompiler { self } + pub fn with_already_emitted_types(mut self, types: HashSet) -> Self { + self.already_emitted_types = types; + self + } + pub fn compile(&self) -> TypeScriptOutput { let imports = self.generate_imports(); let interfaces = self.generate_interfaces(); @@ -533,7 +540,9 @@ function listView(view: string): ViewDef { let registry = crate::resolvers::builtin_resolver_registry(); for resolver in registry.definitions() { - if self.uses_builtin_type(resolver.output_type()) { + if self.uses_builtin_type(resolver.output_type()) + && !self.already_emitted_types.contains(resolver.output_type()) + { if let Some(schema) = resolver.typescript_schema() { schemas.push((schema.name.to_string(), schema.definition.to_string())); } @@ -559,7 +568,9 @@ function listView(view: string): ViewDef { let registry = crate::resolvers::builtin_resolver_registry(); for resolver in registry.definitions() { - if self.uses_builtin_type(resolver.output_type()) { + if self.uses_builtin_type(resolver.output_type()) + && !self.already_emitted_types.contains(resolver.output_type()) + { if let Some(interface) = resolver.typescript_interface() { interfaces.push(interface.to_string()); } @@ -838,7 +849,7 @@ function listView(view: string): ViewDef { fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> { let mut schemas = Vec::new(); - let mut generated_types = HashSet::new(); + let mut generated_types = self.already_emitted_types.clone(); let idl_value = match &self.idl { Some(idl) => idl, @@ -1162,7 +1173,7 @@ export default {};"#, /// Generate nested interfaces for all resolved types in the AST fn generate_nested_interfaces(&self) -> Vec { let mut interfaces = Vec::new(); - let mut generated_types = HashSet::new(); + let mut generated_types = self.already_emitted_types.clone(); // Collect all resolved types from all sections for section in &self.spec.sections { @@ -1644,6 +1655,39 @@ fn value_to_typescript_type(value: &serde_json::Value) -> String { } } +fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet { + let mut names = HashSet::new(); + let registry = crate::resolvers::builtin_resolver_registry(); + for resolver in registry.definitions() { + let output_type = resolver.output_type(); + for section in &spec.sections { + for field in §ion.fields { + if field.inner_type.as_deref() == Some(output_type) { + names.insert(output_type.to_string()); + } + } + } + } + names +} + +fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet { + let mut names = HashSet::new(); + if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) { + for type_def in types_array { + if let (Some(type_name), Some(type_obj)) = ( + type_def.get("name").and_then(|v| v.as_str()), + type_def.get("type").and_then(|v| v.as_object()), + ) { + if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") { + names.insert(type_name.to_string()); + } + } + } + } + names +} + /// Convert snake_case to PascalCase fn to_pascal_case(s: &str) -> String { s.split(['_', '-', '.']) @@ -1719,6 +1763,15 @@ pub fn compile_serializable_spec( spec: SerializableStreamSpec, entity_name: String, config: Option, +) -> Result { + compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new()) +} + +fn compile_serializable_spec_with_emitted( + spec: SerializableStreamSpec, + entity_name: String, + config: Option, + already_emitted_types: HashSet, ) -> Result { let idl = spec .idl @@ -1734,7 +1787,8 @@ pub fn compile_serializable_spec( .with_idl(idl) .with_handlers_json(handlers) .with_views(views) - .with_config(config.unwrap_or_default()); + .with_config(config.unwrap_or_default()) + .with_already_emitted_types(already_emitted_types); Ok(compiler.compile()) } @@ -1799,6 +1853,7 @@ pub fn compile_stack_spec( let mut all_interfaces = Vec::new(); let mut entity_names = Vec::new(); let mut schema_names: Vec = Vec::new(); + let mut emitted_types: HashSet = HashSet::new(); for entity_spec in &stack_spec.entities { let mut spec = entity_spec.clone(); @@ -1817,7 +1872,25 @@ pub fn compile_stack_spec( url: config.url.clone(), }; - let output = compile_serializable_spec(spec, entity_name, Some(per_entity_config))?; + // Collect shared type names before spec is consumed + let idl_enum_names = spec + .idl + .as_ref() + .and_then(|idl| serde_json::to_value(idl).ok()) + .map(|v| extract_idl_enum_type_names(&v)) + .unwrap_or_default(); + let builtin_type_names = extract_builtin_resolver_type_names(&spec); + + let output = compile_serializable_spec_with_emitted( + spec, + entity_name, + Some(per_entity_config), + emitted_types.clone(), + )?; + + // Track shared types for cross-entity dedup + emitted_types.extend(idl_enum_names); + emitted_types.extend(builtin_type_names); // Only take the interfaces part (not the stack_definition — we generate our own) if !output.interfaces.is_empty() { diff --git a/interpreter/src/vm.rs b/interpreter/src/vm.rs index f6a088e8..c2b474f7 100644 --- a/interpreter/src/vm.rs +++ b/interpreter/src/vm.rs @@ -1537,8 +1537,12 @@ impl VmContext { } if mutations.is_empty() { + // CPI events (suffix "CpiEvent") are transaction-scoped like instructions + // (suffix "IxState") and should be queued the same way when PDA lookup fails. + let is_tx_event = + event_type.ends_with("IxState") || event_type.ends_with("CpiEvent"); if let Some(missed_pda) = self.take_last_pda_lookup_miss() { - if event_type.ends_with("IxState") { + if is_tx_event { let slot = context.and_then(|c| c.slot).unwrap_or(0); let signature = context .and_then(|c| c.signature.clone()) @@ -1553,11 +1557,34 @@ impl VmContext { signature, }, ); + } else { + // Queue account updates (e.g. BondingCurve) when PDA + // reverse lookup fails. These will be flushed when an + // instruction registers the PDA mapping via + // UpdateLookupIndex. + let slot = context.and_then(|c| c.slot).unwrap_or(0); + let signature = context + .and_then(|c| c.signature.clone()) + .unwrap_or_default(); + if let Some(write_version) = + context.and_then(|c| c.write_version) + { + let _ = self.queue_account_update( + entity_bytecode.state_id, + QueuedAccountUpdate { + pda_address: missed_pda, + account_type: event_type.to_string(), + account_data: event_value.clone(), + slot, + write_version, + signature, + }, + ); + } } } - if let Some(missed_lookup) = self.take_last_lookup_index_miss() { - if !event_type.ends_with("IxState") { + if !is_tx_event { let slot = context.and_then(|c| c.slot).unwrap_or(0); let signature = context .and_then(|c| c.signature.clone()) @@ -1583,7 +1610,7 @@ impl VmContext { all_mutations.extend(mutations); - if event_type.ends_with("IxState") { + if event_type.ends_with("IxState") || event_type.ends_with("CpiEvent") { if let Some(ctx) = context { if let Some(ref signature) = ctx.signature { if let Some(state) = self.states.get(&entity_bytecode.state_id) @@ -1653,6 +1680,13 @@ impl VmContext { } let lookup_keys = self.take_last_lookup_index_keys(); + if !lookup_keys.is_empty() { + tracing::info!( + keys = ?lookup_keys, + entity = %entity_name, + "vm.process_event: flushing pending updates for lookup_keys" + ); + } for lookup_key in lookup_keys { if let Ok(pending_updates) = self.flush_pending_updates(entity_bytecode.state_id, &lookup_key) @@ -1666,7 +1700,7 @@ impl VmContext { pending.signature.clone(), pending.write_version, )); - if let Ok(reprocessed) = self.execute_handler( + match self.execute_handler( pending_handler, &pending.account_data, &pending.account_type, @@ -1675,7 +1709,16 @@ impl VmContext { entity_bytecode.computed_fields_evaluator.as_ref(), Some(&entity_bytecode.non_emitted_fields), ) { - all_mutations.extend(reprocessed); + Ok(reprocessed) => { + all_mutations.extend(reprocessed); + } + Err(e) => { + tracing::warn!( + error = %e, + account_type = %pending.account_type, + "Flushed event reprocessing failed" + ); + } } } } @@ -1866,9 +1909,11 @@ impl VmContext { deferred_when_ops: DashMap::new(), }); let key_value = self.registers[*key].clone(); + // Warn if key is null for account state events (not instruction events or CPI events) let warn_null_key = key_value.is_null() && event_type.ends_with("State") - && !event_type.ends_with("IxState"); + && !event_type.ends_with("IxState") + && !event_type.ends_with("CpiEvent"); if warn_null_key { self.add_warning(format!( @@ -2818,10 +2863,18 @@ impl VmContext { for segment in path.segments.iter() { current = match current.get(segment) { Some(v) => v, - None => return Ok(default.cloned().unwrap_or(Value::Null)), + None => { + tracing::debug!( + "load_field: segment={:?} not found in {:?}, returning default", + segment, + current + ); + return Ok(default.cloned().unwrap_or(Value::Null)); + } }; } + tracing::debug!("load_field: path={:?}, result={:?}", path.segments, current); Ok(current.clone()) } @@ -2994,6 +3047,19 @@ impl VmContext { let new_value = &self.registers[value_reg]; // Extract numeric value before borrowing object_reg mutably + tracing::debug!( + "set_field_sum: path={:?}, value={:?}, value_type={}", + path, + new_value, + match new_value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } + ); let new_val_num = new_value .as_i64() .or_else(|| new_value.as_u64().map(|n| n as i64)) From dc0eb5e76dba336ec3cade95fa882755ac057a75 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Fri, 13 Mar 2026 22:47:25 +0000 Subject: [PATCH 08/23] feat: update hyperstack lib exports --- hyperstack/src/lib.rs | 147 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/hyperstack/src/lib.rs b/hyperstack/src/lib.rs index 7729e217..385043f6 100644 --- a/hyperstack/src/lib.rs +++ b/hyperstack/src/lib.rs @@ -95,6 +95,153 @@ pub mod runtime { Ok(arr) } } + + /// Serde helper for arrays larger than 32 elements. + /// + /// serde's derive macro doesn't support const generics for arrays > 32. + /// This module serializes arrays as sequences (like Vec) and deserializes + /// them back into fixed-size arrays. + /// + /// Usage: `#[serde(with = "hyperstack::runtime::serde_helpers::big_array")]` + pub mod big_array { + use serde::{ + de::{Deserialize, Deserializer, Error, SeqAccess, Visitor}, + ser::{Serialize, SerializeSeq, Serializer}, + }; + use std::fmt; + use std::marker::PhantomData; + + /// Serialize a fixed-size array as a sequence. + pub fn serialize( + arr: &[T; N], + serializer: S, + ) -> Result + where + S: Serializer, + T: Serialize, + { + let mut seq = serializer.serialize_seq(Some(N))?; + for elem in arr.iter() { + seq.serialize_element(elem)?; + } + seq.end() + } + + /// Deserialize a sequence into a fixed-size array. + pub fn deserialize<'de, D, T, const N: usize>( + deserializer: D, + ) -> Result<[T; N], D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de> + Default + Copy, + { + struct ArrayVisitor(PhantomData); + + impl<'de, T, const N: usize> Visitor<'de> for ArrayVisitor + where + T: Deserialize<'de> + Default + Copy, + { + type Value = [T; N]; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "an array of {} elements", N) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut arr = [T::default(); N]; + for (i, elem) in arr.iter_mut().enumerate() { + *elem = seq + .next_element()? + .ok_or_else(|| Error::invalid_length(i, &self))?; + } + Ok(arr) + } + } + + deserializer.deserialize_seq(ArrayVisitor::(PhantomData)) + } + + #[cfg(test)] + mod tests { + use serde::{Deserialize, Serialize}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct LargeArrayStruct { + #[serde(with = "super")] + data: [u64; 70], + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct NestedLargeArray { + name: String, + #[serde(with = "super")] + values: [u8; 128], + } + + #[test] + fn test_serialize_large_array() { + let s = LargeArrayStruct { data: [42u64; 70] }; + let json = serde_json::to_string(&s).unwrap(); + + // Should serialize as JSON array + assert!(json.starts_with("{\"data\":[")); + assert!(json.contains("42")); + } + + #[test] + fn test_deserialize_large_array() { + let json = r#"{"data":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70]}"#; + let s: LargeArrayStruct = serde_json::from_str(json).unwrap(); + + assert_eq!(s.data[0], 1); + assert_eq!(s.data[69], 70); + assert_eq!(s.data.len(), 70); + } + + #[test] + fn test_roundtrip_large_array() { + let original = LargeArrayStruct { + data: { + let mut arr = [0u64; 70]; + for (i, v) in arr.iter_mut().enumerate() { + *v = i as u64; + } + arr + }, + }; + + let json = serde_json::to_string(&original).unwrap(); + let restored: LargeArrayStruct = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, restored); + } + + #[test] + fn test_nested_struct_with_large_array() { + let original = NestedLargeArray { + name: "test".to_string(), + values: [255u8; 128], + }; + + let json = serde_json::to_string(&original).unwrap(); + let restored: NestedLargeArray = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, restored); + } + + #[test] + fn test_deserialize_wrong_length_fails() { + // Only 69 elements instead of 70 + let json = r#"{"data":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69]}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } + } + } } } From acd5a31d63997426f23b9cd42f26fcef7ff59f97 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 01:49:28 +0000 Subject: [PATCH 09/23] fix: resolve compilation errors in hyperstack-macros Add missing imports for UrlSource and UrlTemplatePart from crate::ast. Fix variable name from url_val to url_path in URL resolver config. --- hyperstack-macros/src/stream_spec/entity.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index 374b07c5..e075f8fc 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -20,6 +20,7 @@ use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type}; use crate::ast::{ EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig, + UrlSource, UrlTemplatePart, }; use crate::codegen; use crate::event_type_helpers::IdlLookup; @@ -472,9 +473,9 @@ pub fn process_entity_struct_with_idl( .unwrap_or(HttpMethod::Get); let url_source = if resolve_attr.url_is_template { - UrlSource::Template(parse_url_template(&url_val)) + UrlSource::Template(parse_url_template(&url_path)) } else { - UrlSource::FieldPath(url_val) + UrlSource::FieldPath(url_path) }; ResolverType::Url(UrlResolverConfig { From 3fee1b124e54a55c886844e9fd0c3bc82bfa1995 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 14:08:17 +0000 Subject: [PATCH 10/23] fix: silence unused variable warning in stream_spec Prefix url_path with underscore to fix clippy -D warnings error --- hyperstack-macros/src/stream_spec/sections.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 64c49921..65b00110 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -652,7 +652,7 @@ pub fn process_nested_struct( } }); - let resolver = if let Some(ref url_path) = qualified_url { + let resolver = if let Some(ref _url_path) = qualified_url { let method = resolve_attr .method .as_deref() From a8a62cfc4726b2be7c64b346a1d42f7307d977cb Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 14:18:33 +0000 Subject: [PATCH 11/23] fix: track only actually emitted enum types to prevent over-eager deduplication --- interpreter/src/typescript.rs | 45 ++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/interpreter/src/typescript.rs b/interpreter/src/typescript.rs index 2994c77a..a529ccf2 100644 --- a/interpreter/src/typescript.rs +++ b/interpreter/src/typescript.rs @@ -1688,6 +1688,39 @@ fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet { names } +/// Extract enum type names that were actually emitted in the generated interfaces. +/// Looks for patterns like `export const DirectionKindSchema = z.enum([...])` +fn extract_emitted_enum_type_names( + interfaces: &str, + spec: &SerializableStreamSpec, +) -> HashSet { + let mut names = HashSet::new(); + + // Get all enum type names from the IDL + let idl_enum_names: HashSet = spec + .idl + .as_ref() + .and_then(|idl| serde_json::to_value(idl).ok()) + .map(|v| extract_idl_enum_type_names(&v)) + .unwrap_or_default(); + + // Look for emitted enum schemas in the interfaces + // Pattern: export const DirectionKindSchema = z.enum([...]) + for line in interfaces.lines() { + if let Some(start) = line.find("export const ") { + if let Some(end) = line.find("Schema = z.enum") { + let schema_name = line[start + 13..end].trim(); + // Check if this schema name corresponds to an IDL enum type + if idl_enum_names.contains(schema_name) { + names.insert(schema_name.to_string()); + } + } + } + } + + names +} + /// Convert snake_case to PascalCase fn to_pascal_case(s: &str) -> String { s.split(['_', '-', '.']) @@ -1872,13 +1905,7 @@ pub fn compile_stack_spec( url: config.url.clone(), }; - // Collect shared type names before spec is consumed - let idl_enum_names = spec - .idl - .as_ref() - .and_then(|idl| serde_json::to_value(idl).ok()) - .map(|v| extract_idl_enum_type_names(&v)) - .unwrap_or_default(); + // Collect builtin type names before spec is consumed let builtin_type_names = extract_builtin_resolver_type_names(&spec); let output = compile_serializable_spec_with_emitted( @@ -1889,7 +1916,9 @@ pub fn compile_stack_spec( )?; // Track shared types for cross-entity dedup - emitted_types.extend(idl_enum_names); + // Only track enum types that were actually emitted (found in output.interfaces) + let emitted_enum_names = extract_emitted_enum_type_names(&output.interfaces, &spec); + emitted_types.extend(emitted_enum_names); emitted_types.extend(builtin_type_names); // Only take the interfaces part (not the stack_definition — we generate our own) From d338399339c231ff150bc9b829e2ea9296dd48ee Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 14:18:47 +0000 Subject: [PATCH 12/23] fix: remove redundant is_cpi_event variable shadowing --- .../src/stream_spec/ast_writer.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index c24efdc2..adbb5f00 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -201,9 +201,10 @@ fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec String { ResolverType::Url(config) => match &config.url_source { crate::ast::UrlSource::FieldPath(path) => format!("url:{}", path), crate::ast::UrlSource::Template(parts) => { - let key: String = parts.iter().map(|p| match p { - crate::ast::UrlTemplatePart::Literal(s) => s.clone(), - crate::ast::UrlTemplatePart::FieldRef(f) => format!("{{{}}}", f), - }).collect(); + let key: String = parts + .iter() + .map(|p| match p { + crate::ast::UrlTemplatePart::Literal(s) => s.clone(), + crate::ast::UrlTemplatePart::FieldRef(f) => format!("{{{}}}", f), + }) + .collect(); format!("url:{}", key) } }, @@ -674,7 +678,6 @@ fn build_source_handler( // - CPI events (from `::events::` submodule) use "CpiEvent" // - Instructions use "IxState" // - Account state uses "State" - let is_cpi_event = source_type.contains("::events::"); let type_suffix = if is_cpi_event { "CpiEvent" } else if is_instruction { From 0bdd7d4d0ab2c1c3b31a50ada22205fe0e95f9f7 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 14:21:01 +0000 Subject: [PATCH 13/23] fix: track only actually emitted enum types to prevent over-eager deduplication --- interpreter/src/typescript.rs | 14 ++++++-------- interpreter/src/vm.rs | 24 +++++++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/interpreter/src/typescript.rs b/interpreter/src/typescript.rs index a529ccf2..e16241d0 100644 --- a/interpreter/src/typescript.rs +++ b/interpreter/src/typescript.rs @@ -1690,16 +1690,11 @@ fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet { /// Extract enum type names that were actually emitted in the generated interfaces. /// Looks for patterns like `export const DirectionKindSchema = z.enum([...])` -fn extract_emitted_enum_type_names( - interfaces: &str, - spec: &SerializableStreamSpec, -) -> HashSet { +fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet { let mut names = HashSet::new(); // Get all enum type names from the IDL - let idl_enum_names: HashSet = spec - .idl - .as_ref() + let idl_enum_names: HashSet = idl .and_then(|idl| serde_json::to_value(idl).ok()) .map(|v| extract_idl_enum_type_names(&v)) .unwrap_or_default(); @@ -1907,6 +1902,8 @@ pub fn compile_stack_spec( // Collect builtin type names before spec is consumed let builtin_type_names = extract_builtin_resolver_type_names(&spec); + // Clone IDL before spec is moved so we can check which enums were emitted + let idl_for_check = spec.idl.clone(); let output = compile_serializable_spec_with_emitted( spec, @@ -1917,7 +1914,8 @@ pub fn compile_stack_spec( // Track shared types for cross-entity dedup // Only track enum types that were actually emitted (found in output.interfaces) - let emitted_enum_names = extract_emitted_enum_type_names(&output.interfaces, &spec); + let emitted_enum_names = + extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref()); emitted_types.extend(emitted_enum_names); emitted_types.extend(builtin_type_names); diff --git a/interpreter/src/vm.rs b/interpreter/src/vm.rs index c2b474f7..5abb910e 100644 --- a/interpreter/src/vm.rs +++ b/interpreter/src/vm.rs @@ -1580,6 +1580,11 @@ impl VmContext { signature, }, ); + } else { + tracing::warn!( + event_type = %event_type, + "Dropping queued account update: write_version missing from context" + ); } } } @@ -1603,6 +1608,11 @@ impl VmContext { signature, }, ); + } else { + tracing::warn!( + event_type = %event_type, + "Dropping queued account update: write_version missing from context" + ); } } } @@ -2634,11 +2644,9 @@ impl VmContext { // Evaluate condition if present if let Some(cond) = condition { - let field_val = Self::get_value_at_path( - &self.registers[*state], - &cond.field_path, - ) - .unwrap_or(Value::Null); + let field_val = + Self::get_value_at_path(&self.registers[*state], &cond.field_path) + .unwrap_or(Value::Null); if !self.evaluate_comparison(&field_val, &cond.op, &cond.value)? { pc += 1; continue; @@ -2647,10 +2655,8 @@ impl VmContext { // Check schedule_at: defer if target slot is in the future if let Some(schedule_path) = schedule_at { - let target_val = Self::get_value_at_path( - &self.registers[*state], - schedule_path, - ); + let target_val = + Self::get_value_at_path(&self.registers[*state], schedule_path); match target_val.and_then(|v| v.as_u64()) { Some(target_slot) => { From cdb0d391903c1382eca2b81bd9867172cf028ca3 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:06:54 +0000 Subject: [PATCH 14/23] fix: panic on missing event type definition instead of silent empty struct --- hyperstack-macros/src/idl_codegen.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hyperstack-macros/src/idl_codegen.rs b/hyperstack-macros/src/idl_codegen.rs index 1eea50e6..2c2d8d44 100644 --- a/hyperstack-macros/src/idl_codegen.rs +++ b/hyperstack-macros/src/idl_codegen.rs @@ -586,7 +586,12 @@ fn generate_event_type( IdlTypeDefKind::Struct { fields, .. } => Some(fields.clone()), _ => None, }) - .unwrap_or_default(); + .unwrap_or_else(|| { + panic!( + "Event '{}' has no matching type definition in the IDL `types` array", + event.name + ) + }); let fields = generate_struct_fields(&idl_fields, false, account_names, false); let to_json_method = generate_struct_to_json_method(&idl_fields, false); From c101bc5f3dcc6d9993d94b233b06d1a364d343ca Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:22:07 +0000 Subject: [PATCH 15/23] fix: reject oversized sequences in big_array deserializer --- hyperstack/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hyperstack/src/lib.rs b/hyperstack/src/lib.rs index 385043f6..24d69802 100644 --- a/hyperstack/src/lib.rs +++ b/hyperstack/src/lib.rs @@ -63,6 +63,7 @@ pub mod runtime { pub use bs58; pub use bytemuck; pub use dotenvy; + pub use futures; pub use hyperstack_interpreter; pub use hyperstack_server; pub use reqwest; @@ -70,7 +71,6 @@ pub mod runtime { pub use serde_json; pub use smallvec; pub use tokio; - pub use futures; pub use tracing; pub use yellowstone_vixen; pub use yellowstone_vixen_core; @@ -157,6 +157,10 @@ pub mod runtime { .next_element()? .ok_or_else(|| Error::invalid_length(i, &self))?; } + // Reject oversized sequences to avoid silent data loss + if seq.next_element::()?.is_some() { + return Err(Error::invalid_length(N + 1, &self)); + } Ok(arr) } } From f8a5223807df4b5dec0fbf0456c06ab67bfeb852 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:22:11 +0000 Subject: [PATCH 16/23] fix: address code review issues in interpreter VM --- interpreter/src/vm.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/interpreter/src/vm.rs b/interpreter/src/vm.rs index 5abb910e..44c4e438 100644 --- a/interpreter/src/vm.rs +++ b/interpreter/src/vm.rs @@ -1609,9 +1609,9 @@ impl VmContext { }, ); } else { - tracing::warn!( + tracing::trace!( event_type = %event_type, - "Dropping queued account update: write_version missing from context" + "Discarding lookup_index_miss for tx-scoped event (IxState/CpiEvent do not use lookup-index queuing)" ); } } @@ -2870,7 +2870,7 @@ impl VmContext { current = match current.get(segment) { Some(v) => v, None => { - tracing::debug!( + tracing::trace!( "load_field: segment={:?} not found in {:?}, returning default", segment, current @@ -2880,7 +2880,7 @@ impl VmContext { }; } - tracing::debug!("load_field: path={:?}, result={:?}", path.segments, current); + tracing::trace!("load_field: path={:?}, result={:?}", path.segments, current); Ok(current.clone()) } @@ -3053,7 +3053,7 @@ impl VmContext { let new_value = &self.registers[value_reg]; // Extract numeric value before borrowing object_reg mutably - tracing::debug!( + tracing::trace!( "set_field_sum: path={:?}, value={:?}, value_type={}", path, new_value, From 258325fea7f7fda18b9faccf14d6fe821431324f Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:28:18 +0000 Subject: [PATCH 17/23] fix: add event type definitions to ore IDL The hyperstack macro requires events to have matching type definitions in the IDL `types` array to generate event struct code. Added type definitions for ResetEvent, BuryEvent, DeployEvent, and LiqEvent to fix the "has no matching type definition" compilation error. --- stacks/ore/idl/ore.json | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/stacks/ore/idl/ore.json b/stacks/ore/idl/ore.json index e4485200..01667cf3 100644 --- a/stacks/ore/idl/ore.json +++ b/stacks/ore/idl/ore.json @@ -1422,6 +1422,72 @@ } ] } + }, + { + "name": "ResetEvent", + "type": { + "kind": "struct", + "fields": [ + {"name": "disc", "type": "u64"}, + {"name": "round_id", "type": "u64"}, + {"name": "start_slot", "type": "u64"}, + {"name": "end_slot", "type": "u64"}, + {"name": "winning_square", "type": "u64"}, + {"name": "top_miner", "type": "publicKey"}, + {"name": "num_winners", "type": "u64"}, + {"name": "motherlode", "type": "u64"}, + {"name": "total_deployed", "type": "u64"}, + {"name": "total_vaulted", "type": "u64"}, + {"name": "total_winnings", "type": "u64"}, + {"name": "total_minted", "type": "u64"}, + {"name": "ts", "type": "i64"}, + {"name": "rng", "type": "u64"}, + {"name": "deployed_winning_square", "type": "u64"} + ] + } + }, + { + "name": "BuryEvent", + "type": { + "kind": "struct", + "fields": [ + {"name": "disc", "type": "u64"}, + {"name": "ore_buried", "type": "u64"}, + {"name": "ore_shared", "type": "u64"}, + {"name": "sol_amount", "type": "u64"}, + {"name": "new_circulating_supply", "type": "u64"}, + {"name": "ts", "type": "i64"} + ] + } + }, + { + "name": "DeployEvent", + "type": { + "kind": "struct", + "fields": [ + {"name": "disc", "type": "u64"}, + {"name": "authority", "type": "publicKey"}, + {"name": "amount", "type": "u64"}, + {"name": "mask", "type": "u64"}, + {"name": "round_id", "type": "u64"}, + {"name": "signer", "type": "publicKey"}, + {"name": "strategy", "type": "u64"}, + {"name": "total_squares", "type": "u64"}, + {"name": "ts", "type": "i64"} + ] + } + }, + { + "name": "LiqEvent", + "type": { + "kind": "struct", + "fields": [ + {"name": "disc", "type": "u64"}, + {"name": "sol_amount", "type": "u64"}, + {"name": "recipient", "type": "publicKey"}, + {"name": "ts", "type": "i64"} + ] + } } ], "events": [ From d7454234e2921cc61df555b0eda68c19a6dc168a Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:28:55 +0000 Subject: [PATCH 18/23] chore: Update stack --- stacks/ore/.hyperstack/OreStream.stack.json | 456 +++++++++----------- stacks/ore/Cargo.lock | 2 + 2 files changed, 208 insertions(+), 250 deletions(-) diff --git a/stacks/ore/.hyperstack/OreStream.stack.json b/stacks/ore/.hyperstack/OreStream.stack.json index b6461063..b8144570 100644 --- a/stacks/ore/.hyperstack/OreStream.stack.json +++ b/stacks/ore/.hyperstack/OreStream.stack.json @@ -1497,6 +1497,178 @@ ] } }, + { + "name": "ResetEvent", + "docs": [], + "type": { + "kind": "struct", + "fields": [ + { + "name": "disc", + "type": "u64" + }, + { + "name": "round_id", + "type": "u64" + }, + { + "name": "start_slot", + "type": "u64" + }, + { + "name": "end_slot", + "type": "u64" + }, + { + "name": "winning_square", + "type": "u64" + }, + { + "name": "top_miner", + "type": "publicKey" + }, + { + "name": "num_winners", + "type": "u64" + }, + { + "name": "motherlode", + "type": "u64" + }, + { + "name": "total_deployed", + "type": "u64" + }, + { + "name": "total_vaulted", + "type": "u64" + }, + { + "name": "total_winnings", + "type": "u64" + }, + { + "name": "total_minted", + "type": "u64" + }, + { + "name": "ts", + "type": "i64" + }, + { + "name": "rng", + "type": "u64" + }, + { + "name": "deployed_winning_square", + "type": "u64" + } + ] + } + }, + { + "name": "BuryEvent", + "docs": [], + "type": { + "kind": "struct", + "fields": [ + { + "name": "disc", + "type": "u64" + }, + { + "name": "ore_buried", + "type": "u64" + }, + { + "name": "ore_shared", + "type": "u64" + }, + { + "name": "sol_amount", + "type": "u64" + }, + { + "name": "new_circulating_supply", + "type": "u64" + }, + { + "name": "ts", + "type": "i64" + } + ] + } + }, + { + "name": "DeployEvent", + "docs": [], + "type": { + "kind": "struct", + "fields": [ + { + "name": "disc", + "type": "u64" + }, + { + "name": "authority", + "type": "publicKey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "mask", + "type": "u64" + }, + { + "name": "round_id", + "type": "u64" + }, + { + "name": "signer", + "type": "publicKey" + }, + { + "name": "strategy", + "type": "u64" + }, + { + "name": "total_squares", + "type": "u64" + }, + { + "name": "ts", + "type": "i64" + } + ] + } + }, + { + "name": "LiqEvent", + "docs": [], + "type": { + "kind": "struct", + "fields": [ + { + "name": "disc", + "type": "u64" + }, + { + "name": "sol_amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + }, + { + "name": "ts", + "type": "i64" + } + ] + } + }, { "name": "Automation", "docs": [ @@ -3707,6 +3879,15 @@ } }, "resolver_hooks": [ + { + "account_type": "entropy::VarState", + "strategy": { + "PdaReverseLookup": { + "lookup_name": "default_pda_lookup", + "queue_discriminators": [] + } + } + }, { "account_type": "ore::TreasuryState", "strategy": { @@ -3726,15 +3907,6 @@ ] } } - }, - { - "account_type": "entropy::VarState", - "strategy": { - "PdaReverseLookup": { - "lookup_name": "default_pda_lookup", - "queue_discriminators": [] - } - } } ], "instruction_hooks": [ @@ -3790,14 +3962,14 @@ "pda_field": { "segments": [ "accounts", - "treasury" + "entropyVar" ], "offsets": null }, "seed_field": { "segments": [ "accounts", - "roundNext" + "round" ], "offsets": null }, @@ -4613,7 +4785,7 @@ "result_type": "Option < f64 >" } ], - "content_hash": "6656d3208291c8ac7d8fc757727ffd176be3a42fc66624e002623cc0ca840a16", + "content_hash": "93f4b9ed6412e15ba1d92475355f5b1d2f5b8b81358ec01e620dbf50ff0b79dd", "views": [ { "id": "OreRound/latest", @@ -5101,6 +5273,15 @@ } }, "resolver_hooks": [ + { + "account_type": "entropy::VarState", + "strategy": { + "PdaReverseLookup": { + "lookup_name": "default_pda_lookup", + "queue_discriminators": [] + } + } + }, { "account_type": "ore::TreasuryState", "strategy": { @@ -5120,123 +5301,6 @@ ] } } - }, - { - "account_type": "entropy::VarState", - "strategy": { - "PdaReverseLookup": { - "lookup_name": "default_pda_lookup", - "queue_discriminators": [] - } - } - } - ], - "instruction_hooks": [ - { - "instruction_type": "ore::DeployIxState", - "actions": [ - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "entropyVar" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "round" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - }, - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "entropyVar" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "round" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - } - ], - "lookup_by": null - }, - { - "instruction_type": "ore::ResetIxState", - "actions": [ - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "treasury" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "roundNext" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - }, - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "entropyVar" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "round" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - }, - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "treasury" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "roundNext" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - } - } } ], "instruction_hooks": [], @@ -5337,7 +5401,7 @@ "result_type": "Option < f64 >" } ], - "content_hash": "6940e0c98d4f1b4ecc6592fb4b2fe825950f1205860b47de7723e81e7c921204", + "content_hash": "6c304a5351de588793a9394e722ea4870805730d38b3a1782a970400b4fc3f36", "views": [] }, { @@ -6661,6 +6725,15 @@ } }, "resolver_hooks": [ + { + "account_type": "entropy::VarState", + "strategy": { + "PdaReverseLookup": { + "lookup_name": "default_pda_lookup", + "queue_discriminators": [] + } + } + }, { "account_type": "ore::TreasuryState", "strategy": { @@ -6680,130 +6753,13 @@ ] } } - }, - { - "account_type": "entropy::VarState", - "strategy": { - "PdaReverseLookup": { - "lookup_name": "default_pda_lookup", - "queue_discriminators": [] - } - } - } - ], - "instruction_hooks": [ - { - "instruction_type": "ore::DeployIxState", - "actions": [ - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "entropyVar" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "round" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - }, - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "entropyVar" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "round" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - } - ], - "lookup_by": null - }, - { - "instruction_type": "ore::ResetIxState", - "actions": [ - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "treasury" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "roundNext" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - }, - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "entropyVar" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "round" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - }, - { - "RegisterPdaMapping": { - "pda_field": { - "segments": [ - "accounts", - "treasury" - ], - "offsets": null - }, - "seed_field": { - "segments": [ - "accounts", - "roundNext" - ], - "offsets": null - }, - "lookup_name": "default_pda_lookup" - } - } - } } ], "instruction_hooks": [], "resolver_specs": [], "computed_fields": [], "computed_field_specs": [], - "content_hash": "cc153e8334a5d6bff0710db82de32f84037aaee14db0eeb7f443209e23f02e71", + "content_hash": "f6a0a3a397a148fa34256295b78d8ce504484bb768958fa8593d4cd1b6a3bac7", "views": [] } ], @@ -8911,5 +8867,5 @@ ] } ], - "content_hash": "e3f5ab05df7fb576313c02f6f942748f9ef597a1df30de66fa7a24a6cfe39f25" -} + "content_hash": "f6e4c04a606dddbd67be4924d7fa529569af4bef53760a7259f5da1914ea2da6" +} \ No newline at end of file diff --git a/stacks/ore/Cargo.lock b/stacks/ore/Cargo.lock index 6f8e1505..e1a9bdb2 100644 --- a/stacks/ore/Cargo.lock +++ b/stacks/ore/Cargo.lock @@ -1144,6 +1144,7 @@ dependencies = [ "bs58", "bytemuck", "dotenvy", + "futures", "hyperstack-interpreter", "hyperstack-macros", "hyperstack-sdk", @@ -1180,6 +1181,7 @@ dependencies = [ "hyperstack-idl", "hyperstack-macros", "lru", + "percent-encoding", "prost 0.13.5", "prost-reflect", "prost-types 0.13.5", From 908c9ff757976336483b89c24fd8f75640f58029 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:57:43 +0000 Subject: [PATCH 19/23] fix: address code review issues in macro codegen - Remove unused `hooks_count` variable in vixen_runtime.rs - Remove redundant `is_cpi_event_for_type` shadowing in writer.rs - Add discriminator validation to `try_from_bytes` in idl_codegen.rs --- hyperstack-macros/src/ast/writer.rs | 3 +-- hyperstack-macros/src/codegen/vixen_runtime.rs | 1 - hyperstack-macros/src/idl_codegen.rs | 6 ++++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/hyperstack-macros/src/ast/writer.rs b/hyperstack-macros/src/ast/writer.rs index 9aeb22f3..fc3d513a 100644 --- a/hyperstack-macros/src/ast/writer.rs +++ b/hyperstack-macros/src/ast/writer.rs @@ -565,8 +565,7 @@ pub fn build_handlers_from_sources( }; // CPI events (from `::events::`) use "CpiEvent" suffix; instructions use "IxState" - let is_cpi_event_for_type = source_type.contains("::events::"); - let type_suffix = if is_cpi_event_for_type { + let type_suffix = if is_cpi_event { "CpiEvent" } else if is_instruction { "IxState" diff --git a/hyperstack-macros/src/codegen/vixen_runtime.rs b/hyperstack-macros/src/codegen/vixen_runtime.rs index be89e092..ef75199e 100644 --- a/hyperstack-macros/src/codegen/vixen_runtime.rs +++ b/hyperstack-macros/src/codegen/vixen_runtime.rs @@ -1896,7 +1896,6 @@ pub fn generate_instruction_handler_impl( } let pending_updates = ctx.take_pending_updates(); - let hooks_count = hooks.len(); drop(ctx); diff --git a/hyperstack-macros/src/idl_codegen.rs b/hyperstack-macros/src/idl_codegen.rs index 2c2d8d44..f997e73d 100644 --- a/hyperstack-macros/src/idl_codegen.rs +++ b/hyperstack-macros/src/idl_codegen.rs @@ -611,6 +611,12 @@ fn generate_event_type( if data.len() < 8 { return Err("Data too short for event discriminator".into()); } + if data[..8] != Self::DISCRIMINATOR { + return Err(format!( + "Discriminator mismatch: expected {:?}, got {:?}", + Self::DISCRIMINATOR, &data[..8] + ).into()); + } let mut reader = &data[8..]; borsh::BorshDeserialize::deserialize_reader(&mut reader).map_err(|e| e.into()) } From 4e43b39b1142b6846f0ae2a62ea75b8b86e917a2 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 15:59:47 +0000 Subject: [PATCH 20/23] chore: Release please config formatting --- release-please-config.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index 217ca5f0..40c99b8a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -84,10 +84,6 @@ "component": "hyperstack-idl" } }, - - - - "plugins": [ { "type": "cargo-workspace", From c9fb961f380002516ae845eaf743e15ea8e47c3c Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 16:33:12 +0000 Subject: [PATCH 21/23] fix(interpreter): zero-variant enum dedup guard escape extract_emitted_enum_type_names only recognised z.enum patterns but zero-variant enums are emitted as z.string(). Extend pattern matching to detect both forms and prevent duplicate identifier errors. --- interpreter/src/typescript.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/interpreter/src/typescript.rs b/interpreter/src/typescript.rs index e16241d0..d7544e8b 100644 --- a/interpreter/src/typescript.rs +++ b/interpreter/src/typescript.rs @@ -1700,10 +1700,13 @@ fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) .unwrap_or_default(); // Look for emitted enum schemas in the interfaces - // Pattern: export const DirectionKindSchema = z.enum([...]) + // Pattern: export const DirectionKindSchema = z.enum([...]) or z.string() for empty variants for line in interfaces.lines() { if let Some(start) = line.find("export const ") { - if let Some(end) = line.find("Schema = z.enum") { + let end = line + .find("Schema = z.enum") + .or_else(|| line.find("Schema = z.string()")); + if let Some(end) = end { let schema_name = line[start + 13..end].trim(); // Check if this schema name corresponds to an IDL enum type if idl_enum_names.contains(schema_name) { From c1057e0929a18cc8352a5cfd46a7695d0715cb68 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 16:33:17 +0000 Subject: [PATCH 22/23] refactor(macros): extract shared resolve_snapshot_source helper Eliminate duplicate (source_field_name, is_whole_source) logic blocks from entity.rs and sections.rs by extracting into a shared helper function in stream_spec/mod.rs. --- hyperstack-macros/src/stream_spec/entity.rs | 25 ++--------------- hyperstack-macros/src/stream_spec/mod.rs | 28 +++++++++++++++++++ hyperstack-macros/src/stream_spec/sections.rs | 24 ++-------------- 3 files changed, 33 insertions(+), 44 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index e075f8fc..b43bf5d0 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -18,6 +18,8 @@ use std::collections::{HashMap, HashSet}; use quote::{format_ident, quote}; use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type}; +use super::resolve_snapshot_source; + use crate::ast::{ EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig, UrlSource, UrlTemplatePart, @@ -328,29 +330,8 @@ pub fn process_entity_struct_with_idl( let source_type_str = path_to_string(&acct_path); // Determine source field name and whether this is a whole-source capture - // or a single-field extraction based on the `field` parameter let (source_field_name, is_whole_source) = - if let Some(ref field_ident) = snapshot_attr.field { - // Single field extraction: field = token_mint_0 - (field_ident.to_string(), false) - } else if !snapshot_attr.field_transforms.is_empty() { - // Whole source with transforms - ( - format!( - "__snapshot_with_transforms:{}", - snapshot_attr - .field_transforms - .iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join(",") - ), - true, - ) - } else { - // Whole source capture (no field, no transforms) - (String::new(), true) - }; + resolve_snapshot_source(&snapshot_attr); let map_attr = parse::MapAttribute { source_type_path: acct_path, diff --git a/hyperstack-macros/src/stream_spec/mod.rs b/hyperstack-macros/src/stream_spec/mod.rs index 10b1c0f7..75118958 100644 --- a/hyperstack-macros/src/stream_spec/mod.rs +++ b/hyperstack-macros/src/stream_spec/mod.rs @@ -50,3 +50,31 @@ pub use module::process_module; // Re-export proto struct processing (used by lib.rs) pub use proto_struct::process_struct_with_context; + +/// Resolves the source field name and whether this is a whole-source capture +/// based on the `field` and `field_transforms` parameters. +pub fn resolve_snapshot_source( + snapshot_attr: &crate::parse::attributes::CaptureAttribute, +) -> (String, bool) { + if let Some(ref field_ident) = snapshot_attr.field { + // Single field extraction: field = token_mint_0 + (field_ident.to_string(), false) + } else if !snapshot_attr.field_transforms.is_empty() { + // Whole source with transforms + ( + format!( + "__snapshot_with_transforms:{}", + snapshot_attr + .field_transforms + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(",") + ), + true, + ) + } else { + // Whole source capture (no field, no transforms) + (String::new(), true) + } +} diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 65b00110..45dec2d0 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -17,6 +17,7 @@ use crate::parse::idl::{IdlSpec, IdlType, IdlTypeDefKind}; use crate::utils::path_to_string; use super::handlers::{determine_event_instruction, extract_account_type_from_field}; +use super::resolve_snapshot_source; // ============================================================================ // Section Extraction @@ -502,29 +503,8 @@ pub fn process_nested_struct( let source_type_str = path_to_string(&acct_path); // Determine source field name and whether this is a whole-source capture - // or a single-field extraction based on the `field` parameter let (source_field_name, is_whole_source) = - if let Some(ref field_ident) = snapshot_attr.field { - // Single field extraction: field = token_mint_0 - (field_ident.to_string(), false) - } else if !snapshot_attr.field_transforms.is_empty() { - // Whole source with transforms - ( - format!( - "__snapshot_with_transforms:{}", - snapshot_attr - .field_transforms - .iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join(",") - ), - true, - ) - } else { - // Whole source capture (no field, no transforms) - (String::new(), true) - }; + resolve_snapshot_source(&snapshot_attr); let map_attr = parse::MapAttribute { source_type_path: acct_path, From d626ac133301551e9d24a054d94b8d305c3f4eef Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 14 Mar 2026 16:33:22 +0000 Subject: [PATCH 23/23] docs(macros): clarify is_large_array only checks outer dimension Add doc comment explaining that is_large_array only inspects the outermost array dimension and nested arrays are not examined. --- hyperstack-macros/src/idl_codegen.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hyperstack-macros/src/idl_codegen.rs b/hyperstack-macros/src/idl_codegen.rs index f997e73d..1af60a39 100644 --- a/hyperstack-macros/src/idl_codegen.rs +++ b/hyperstack-macros/src/idl_codegen.rs @@ -145,7 +145,9 @@ pub fn generate_sdk_types(idl: &IdlSpec, module_name: &str) -> TokenStream { /// Arrays larger than this need a custom serde helper. const SERDE_MAX_ARRAY_SIZE: u32 = 32; -/// Check if a type is a large array (> 32 elements) that needs special serde handling. +/// Check if the outermost array dimension exceeds SERDE_MAX_ARRAY_SIZE. +/// Nested arrays are not inspected; only the outer length matters for the +/// `#[serde(with = "big_array")]` attribute. fn is_large_array(idl_type: &IdlType) -> bool { if let IdlType::Array(arr) = idl_type { if arr.array.len() == 2 {