From ed692048a840aef92054a36ae04c12db0c876c54 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 11:45:16 +0530 Subject: [PATCH 1/4] gnd: Parse tuple components and emit recursive event signatures EventInput now carries nested components for tuple / tuple[] inputs, parsed recursively from the ABI. Event signatures expand tuples, e.g. Foo((address,uint256),uint256), so the topic0 hash matches the on-chain event. Previously a tuple parameter produced Foo(tuple,...) and the handler would never fire. --- gnd/src/scaffold/manifest.rs | 126 +++++++++++++++++++++++++++-------- gnd/src/scaffold/schema.rs | 3 + 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index b0b731ff4be..067dd561e6a 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; +use serde_json::Value; + use super::ScaffoldOptions; use crate::shared::handle_reserved_word; @@ -178,6 +180,8 @@ pub struct EventInput { pub name: String, pub solidity_type: String, pub indexed: bool, + /// Nested fields for `tuple` / `tuple[]` inputs; empty otherwise. + pub components: Vec, } /// Extract events from ABI JSON. @@ -204,23 +208,7 @@ pub fn extract_events_from_abi(options: &ScaffoldOptions) -> Vec { let inputs = item .get("inputs") .and_then(|i| i.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|input| { - let name = input.get("name").and_then(|n| n.as_str())?.to_string(); - let solidity_type = input.get("type").and_then(|t| t.as_str())?.to_string(); - let indexed = input - .get("indexed") - .and_then(|i| i.as_bool()) - .unwrap_or(false); - Some(EventInput { - name, - solidity_type, - indexed, - }) - }) - .collect::>() - }) + .map(|arr| arr.iter().filter_map(parse_event_input).collect::>()) .unwrap_or_default(); let signature = format_event_signature(name, &inputs); @@ -235,22 +223,66 @@ pub fn extract_events_from_abi(options: &ScaffoldOptions) -> Vec { events } -/// Format an event signature string. -fn format_event_signature(name: &str, inputs: &[EventInput]) -> String { - let params: Vec = inputs - .iter() - .map(|input| { - if input.indexed { - format!("indexed {}", input.solidity_type) - } else { - input.solidity_type.clone() - } - }) - .collect(); +/// Recursively parse an ABI input, including nested tuple components. +fn parse_event_input(input: &Value) -> Option { + let name = input.get("name").and_then(|n| n.as_str())?.to_string(); + let solidity_type = input.get("type").and_then(|t| t.as_str())?.to_string(); + let indexed = input + .get("indexed") + .and_then(|i| i.as_bool()) + .unwrap_or(false); + let components = input + .get("components") + .and_then(|c| c.as_array()) + .map(|arr| arr.iter().filter_map(parse_event_input).collect()) + .unwrap_or_default(); + Some(EventInput { + name, + solidity_type, + indexed, + components, + }) +} +/// Format an event signature, recursing into tuple components so the topic0 +/// hash matches the on-chain event, e.g. `Foo((address,uint256),uint256)`. +fn format_event_signature(name: &str, inputs: &[EventInput]) -> String { + let params: Vec = inputs.iter().map(signature_param).collect(); format!("{}({})", name, params.join(",")) } +/// One parameter's signature, preserving the `indexed` marker at the top level. +fn signature_param(input: &EventInput) -> String { + let ty = signature_type(input); + if input.indexed { + format!("indexed {}", ty) + } else { + ty + } +} + +/// Canonical Solidity type for a signature; a tuple expands to its components. +fn signature_type(input: &EventInput) -> String { + match tuple_suffix(&input.solidity_type) { + Some(suffix) => { + let inner: Vec = input.components.iter().map(signature_type).collect(); + format!("({}){}", inner.join(","), suffix) + } + None => input.solidity_type.clone(), + } +} + +/// If `t` is `tuple`, `tuple[]`, `tuple[N]`, … returns the array suffix (possibly +/// empty); otherwise `None`. +fn tuple_suffix(t: &str) -> Option<&str> { + let rest = t.strip_prefix("tuple")?; + if rest.is_empty() || rest.starts_with('[') { + Some(rest) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -385,11 +417,13 @@ mod tests { name: "from".to_string(), solidity_type: "address".to_string(), indexed: true, + components: vec![], }, EventInput { name: "value".to_string(), solidity_type: "uint256".to_string(), indexed: false, + components: vec![], }, ]; @@ -397,6 +431,40 @@ mod tests { assert_eq!(sig, "Transfer(indexed address,uint256)"); } + #[test] + fn test_format_event_signature_tuple() { + let scalar = |name: &str, ty: &str| EventInput { + name: name.to_string(), + solidity_type: ty.to_string(), + indexed: false, + components: vec![], + }; + + // A tuple param expands to its components so topic0 matches the chain. + let inputs = vec![ + EventInput { + name: "data".to_string(), + solidity_type: "tuple".to_string(), + indexed: false, + components: vec![scalar("account", "address"), scalar("amount", "uint256")], + }, + scalar("id", "uint256"), + ]; + assert_eq!( + format_event_signature("Foo", &inputs), + "Foo((address,uint256),uint256)" + ); + + // Arrays of tuples keep the array suffix. + let arr = vec![EventInput { + name: "items".to_string(), + solidity_type: "tuple[]".to_string(), + indexed: false, + components: vec![scalar("a", "address")], + }]; + assert_eq!(format_event_signature("Bar", &arr), "Bar((address)[])"); + } + #[test] fn test_disambiguate_events() { let ev = |name: &str| EventInfo { diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index ed47de3e041..f50784ab05d 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -169,16 +169,19 @@ mod tests { name: "from".to_string(), solidity_type: "address".to_string(), indexed: true, + components: vec![], }, EventInput { name: "to".to_string(), solidity_type: "address".to_string(), indexed: true, + components: vec![], }, EventInput { name: "value".to_string(), solidity_type: "uint256".to_string(), indexed: false, + components: vec![], }, ]; From 5e0306fcafc1f0c596d9d36a871712b1284fe00b Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 11:52:36 +0530 Subject: [PATCH 2/4] gnd: Unroll tuple event params into entity fields A tuple parameter now expands to one field per component (data -> data_a, data_b) in both the schema and the mapping, with nested accessors (event.params.data.a), via a shared flatten_input helper. Arrays of address/tuple are changetype'd to Bytes[] so the assignment type-checks. Previously a tuple collapsed to a single Bytes field that did not match the generated event params. --- gnd/src/scaffold/manifest.rs | 87 ++++++++++++++++++++++++++++++++++++ gnd/src/scaffold/mapping.rs | 79 ++++++++++++++++++++++++++++---- gnd/src/scaffold/mod.rs | 4 +- gnd/src/scaffold/schema.rs | 36 ++++++++++++--- 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index 067dd561e6a..0bb2ea2b6fd 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -283,6 +283,63 @@ fn tuple_suffix(t: &str) -> Option<&str> { } } +/// A flattened leaf of an event input: the entity field name, the matching +/// `event.params` accessor, and the leaf's Solidity type. A single `tuple` +/// expands to one leaf per component (`data` -> `data_a`, `data_b`). +pub struct InputLeaf { + pub field: String, + pub accessor: String, + pub solidity_type: String, +} + +/// Flatten an event's inputs into leaves, unrolling a single `tuple` into its +/// components (`tuple[]` stays one leaf, handled as a Bytes array downstream). +/// The top-level accessor mirrors the generated binding getter +/// (`event_param_accessors`) so `event.params.` resolves; field names are +/// sanitized for the schema/entity side. +pub fn flatten_event_inputs(inputs: &[EventInput]) -> Vec { + let accessors = event_param_accessors(inputs); + let mut leaves = Vec::new(); + for (input, accessor) in inputs.iter().zip(&accessors) { + flatten_input_into( + &mut leaves, + std::slice::from_ref(accessor), + &[super::sanitize_field_name(&input.name)], + input, + ); + } + leaves +} + +fn flatten_input_into( + out: &mut Vec, + accessor_path: &[String], + field_path: &[String], + input: &EventInput, +) { + if input.solidity_type != "tuple" { + out.push(InputLeaf { + field: field_path.join("_"), + accessor: accessor_path.join("."), + solidity_type: input.solidity_type.clone(), + }); + return; + } + + for (i, comp) in input.components.iter().enumerate() { + let (raw, field) = if comp.name.is_empty() { + (format!("value{i}"), format!("value{i}")) + } else { + (comp.name.clone(), super::sanitize_field_name(&comp.name)) + }; + let mut accessor = accessor_path.to_vec(); + accessor.push(raw); + let mut fields = field_path.to_vec(); + fields.push(field); + flatten_input_into(out, &accessor, &fields, comp); + } +} + #[cfg(test)] mod tests { use super::*; @@ -465,6 +522,36 @@ mod tests { assert_eq!(format_event_signature("Bar", &arr), "Bar((address)[])"); } + #[test] + fn test_flatten_input_tuple() { + let scalar = |name: &str, ty: &str| EventInput { + name: name.to_string(), + solidity_type: ty.to_string(), + indexed: false, + components: vec![], + }; + + // A tuple flattens into one leaf per component. + let tuple = EventInput { + name: "data".to_string(), + solidity_type: "tuple".to_string(), + indexed: false, + components: vec![scalar("account", "address"), scalar("amount", "uint256")], + }; + let leaves = flatten_event_inputs(&[tuple]); + assert_eq!(leaves.len(), 2); + assert_eq!(leaves[0].field, "data_account"); + assert_eq!(leaves[0].accessor, "data.account"); + assert_eq!(leaves[1].field, "data_amount"); + assert_eq!(leaves[1].accessor, "data.amount"); + + // A non-tuple input is a single leaf. + let leaves = flatten_event_inputs(&[scalar("from", "address")]); + assert_eq!(leaves.len(), 1); + assert_eq!(leaves[0].field, "from"); + assert_eq!(leaves[0].accessor, "from"); + } + #[test] fn test_disambiguate_events() { let ev = |name: &str| EventInfo { diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index 8749c91ab2b..ecfba396c43 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -201,16 +201,22 @@ fn generate_single_handler(resolved: &ResolvedEvent) -> String { let alias = &resolved.alias; let entity_name = &resolved.entity_name; - // Generate field assignments from event parameters. The accessor mirrors the - // generated binding getter (escaped reserved words, param for unnamed). + // Generate field assignments: tuples are unrolled, arrays of address/tuple + // are changetype'd, and accessors mirror the generated binding getters + // (escaped reserved words, param for unnamed). let mut field_assignments = String::new(); - let accessors = super::event_param_accessors(&resolved.event.inputs); - for (input, accessor) in resolved.event.inputs.iter().zip(&accessors) { - let field_name = sanitize_field_name(&input.name); - field_assignments.push_str(&format!( - " entity.{} = event.params.{}\n", - field_name, accessor - )); + for leaf in super::flatten_event_inputs(&resolved.event.inputs) { + if needs_bytes_array_cast(&leaf.solidity_type) { + field_assignments.push_str(&format!( + " entity.{} = changetype(event.params.{})\n", + leaf.field, leaf.accessor + )); + } else { + field_assignments.push_str(&format!( + " entity.{} = event.params.{}\n", + leaf.field, leaf.accessor + )); + } } format!( @@ -229,6 +235,24 @@ fn generate_single_handler(resolved: &ResolvedEvent) -> String { ) } +/// Whether a leaf type is a (possibly fixed-size) array of `address` or `tuple`. +/// The bindings expose these as `Array
` / `Array`, which +/// must be `changetype`'d to fit a `Bytes[]` entity field. +fn needs_bytes_array_cast(solidity_type: &str) -> bool { + ["address", "tuple"] + .iter() + .any(|base| match solidity_type.strip_prefix(base) { + Some("[]") => true, + Some(rest) => rest + .strip_prefix('[') + .and_then(|r| r.strip_suffix(']')) + .is_some_and(|inner| { + !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) + }), + None => false, + }) +} + /// Extract callable functions from ABI for documentation comments. fn extract_callable_functions(options: &ScaffoldOptions) -> String { let Some(abi) = &options.abi else { @@ -435,4 +459,41 @@ mod tests { mapping ); } + + #[test] + fn test_generate_mapping_tuple_and_array() { + let abi = json!([ + { + "type": "event", + "name": "Deposit", + "inputs": [ + {"name": "data", "type": "tuple", "components": [ + {"name": "account", "type": "address"} + ]}, + {"name": "owners", "type": "address[]"} + ] + } + ]); + + let options = ScaffoldOptions { + contract_name: "Vault".to_string(), + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let mapping = generate_mapping(&options); + // Tuple components are unrolled into nested accessors. + assert!( + mapping.contains("entity.data_account = event.params.data.account"), + "{}", + mapping + ); + // Arrays of address are changetype'd to Bytes[]. + assert!( + mapping.contains("entity.owners = changetype(event.params.owners)"), + "{}", + mapping + ); + } } diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 72cb5d70a37..c19fffe0532 100644 --- a/gnd/src/scaffold/mod.rs +++ b/gnd/src/scaffold/mod.rs @@ -9,8 +9,8 @@ mod naming; mod schema; pub use manifest::{ - EventInfo, EventInput, ResolvedEvent, disambiguate_events, event_param_accessors, - extract_events_from_abi, generate_manifest, + EventInfo, EventInput, InputLeaf, ResolvedEvent, disambiguate_events, event_param_accessors, + extract_events_from_abi, flatten_event_inputs, generate_manifest, }; pub use mapping::{generate_event_handlers, generate_mapping}; pub(crate) use naming::sanitize_field_name; diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index f50784ab05d..2c6ac3059fc 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -62,11 +62,10 @@ pub fn generate_event_entity(entity_name: &str, inputs: &[EventInput]) -> String // ID field fields.push_str(" id: Bytes!\n"); - // Fields from event inputs - for input in inputs { - let field_name = sanitize_field_name(&input.name); - let graphql_type = solidity_to_graphql(&input.solidity_type); - fields.push_str(&format!(" {}: {}!\n", field_name, graphql_type)); + // Fields from event inputs (tuples are unrolled into a field per component). + for leaf in super::flatten_event_inputs(inputs) { + let graphql_type = solidity_to_graphql(&leaf.solidity_type); + fields.push_str(&format!(" {}: {}!\n", leaf.field, graphql_type)); } // Standard blockchain fields @@ -280,4 +279,31 @@ mod tests { assert_eq!(solidity_to_graphql("int8[]"), "[Int!]"); assert_eq!(solidity_to_graphql("uint64[]"), "[BigInt!]"); } + + #[test] + fn test_generate_schema_unrolls_tuple() { + let abi = json!([ + { + "type": "event", + "name": "Deposit", + "inputs": [ + {"name": "data", "type": "tuple", "components": [ + {"name": "account", "type": "address"}, + {"name": "amount", "type": "uint256"} + ]} + ] + } + ]); + + let options = ScaffoldOptions { + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let schema = generate_schema(&options); + // The tuple is flattened into one field per component. + assert!(schema.contains("data_account: Bytes!"), "{}", schema); + assert!(schema.contains("data_amount: BigInt!"), "{}", schema); + } } From dfbf178a0697f5002ca2f474dbb0841959302b2a Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 11:56:53 +0530 Subject: [PATCH 3/4] gnd: Test tuple-param scaffolding end-to-end init from an ABI with a tuple event unrolls it into data_account / data_amount schema fields and reads them via the nested accessor in the generated mapping. --- gnd/tests/cli_commands.rs | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index 192d201c3be..b1b7b13f308 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -651,6 +651,60 @@ fn test_init_overloaded_events_disambiguate() { ); } +#[test] +fn test_init_tuple_event_unrolls() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("tuple-init"); + + // An event with a tuple parameter. + let tuple_abi = temp_dir.path().join("Tuple.json"); + fs::write( + &tuple_abi, + r#"[ + {"type":"event","name":"Deposit","inputs":[ + {"name":"data","type":"tuple","indexed":false,"components":[ + {"name":"account","type":"address"}, + {"name":"amount","type":"uint256"} + ]} + ]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + tuple_abi.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "Vault", + "--index-events", + "tuple-init", + ], + temp_dir.path(), + ); + + // The tuple is unrolled into one schema field per component. + let schema = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema.contains("data_account: Bytes!") && schema.contains("data_amount: BigInt!"), + "tuple should unroll into schema fields, got:\n{}", + schema + ); + + // The mapping reads the components via the nested accessor. + let mapping = fs::read_to_string(subgraph_dir.join("src").join("vault.ts")).unwrap(); + assert!( + mapping.contains("event.params.data.account"), + "mapping should use the nested tuple accessor, got:\n{}", + mapping + ); +} + #[test] fn test_add_duplicate_name_errors() { let temp_dir = TempDir::new().unwrap(); From 5c8dbc1620c38e068a3214f157cfd3412ede317f Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 12:48:33 +0530 Subject: [PATCH 4/4] gnd: Test reserved/unnamed param accessors against codegen Regression guard for the LHS/RHS escaping mismatch: codegen a contract with a reserved-word param (`new`) and an unnamed param, then assert the mapping's event.params accessor matches the getter the bindings actually expose (new_, param1). --- gnd/tests/cli_commands.rs | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index b1b7b13f308..c588de7aa5d 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -705,6 +705,63 @@ fn test_init_tuple_event_unrolls() { ); } +#[test] +fn test_init_reserved_and_unnamed_params_codegen() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("rw-sub"); + + // A reserved-word param (`new`) and an unnamed param. + let abi = temp_dir.path().join("RW.json"); + fs::write( + &abi, + r#"[ + {"type":"event","name":"Act","inputs":[ + {"name":"new","type":"uint256","indexed":false}, + {"name":"","type":"address","indexed":false} + ]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "RW", + "--index-events", + "--skip-install", + "--skip-git", + "rw-sub", + ], + temp_dir.path(), + ); + run_gnd_success(&["codegen"], &subgraph_dir); + + // The mapping's event.params accessor must match the getter the codegen + // actually generates: `new` is escaped to `new_`, the unnamed param is + // `param1`. (Regression guard for the LHS/RHS escaping mismatch.) + let mapping = fs::read_to_string(subgraph_dir.join("src").join("rw.ts")).unwrap(); + let bindings = + fs::read_to_string(subgraph_dir.join("generated").join("RW").join("RW.ts")).unwrap(); + + assert!( + bindings.contains("get new_(") && mapping.contains("event.params.new_"), + "reserved getter and accessor should both be new_\nmapping:\n{}", + mapping + ); + assert!( + bindings.contains("get param1(") && mapping.contains("event.params.param1"), + "unnamed getter and accessor should both be param1\nmapping:\n{}", + mapping + ); +} + #[test] fn test_add_duplicate_name_errors() { let temp_dir = TempDir::new().unwrap();