diff --git a/gnd/src/abi.rs b/gnd/src/abi.rs index 0a5fa695cf4..3e296a99f2d 100644 --- a/gnd/src/abi.rs +++ b/gnd/src/abi.rs @@ -1,20 +1,93 @@ //! ABI normalization utilities. //! //! Handles extraction of bare ABI arrays from various artifact formats -//! (raw arrays, Hardhat/Foundry, Truffle). +//! (raw arrays, Hardhat/Foundry, Truffle), the preprocessing alloy's ABI +//! parser needs (`anonymous` and `param{index}` defaults), and the shared +//! event-signature format the manifest uses. use anyhow::{Context, Result, anyhow}; +use graph::abi::{DynSolType, Event, EventParam}; +use serde_json::Value; + +/// Resolve an `EventParam`'s declared type to `DynSolType`. +pub fn resolve_event_param_type(param: &EventParam) -> DynSolType { + param + .selector_type() + .parse::() + .expect("valid ABI type") +} + +/// The type an indexed param actually carries in the log. +/// +/// A topic is exactly 32 bytes, so reference types (strings, bytes, arrays and +/// tuples) do not fit: the EVM stores `keccak256(encoding)` instead and the +/// value itself is not in the log at all. Value types are stored as-is. +pub fn indexed_input_type(param_type: &DynSolType) -> DynSolType { + match param_type { + DynSolType::String | DynSolType::Bytes | DynSolType::Tuple(_) => DynSolType::FixedBytes(32), + DynSolType::Array(_) | DynSolType::FixedArray(_, _) => DynSolType::FixedBytes(32), + _ => param_type.clone(), + } +} + +/// Whether an event param is reduced to a `bytes32` hash by being indexed, so +/// its value cannot be read back out of the log. +/// +/// A type that does not parse (a malformed ABI can declare `tuple` with no +/// components) is reported as not hashed: scaffolding must not abort on an ABI +/// that alloy accepted. +pub fn is_hashed_when_indexed(param: &EventParam) -> bool { + if !param.indexed { + return false; + } + match param.selector_type().parse::() { + Ok(declared) => indexed_input_type(&declared) != declared, + Err(_) => false, + } +} + +/// Build a manifest event signature, e.g. +/// `Transfer(indexed address,indexed address,uint256)`. +/// +/// The `indexed ` marker is graph-node's own convention: it is stripped before +/// hashing topic0, and only the strict manifest-vs-ABI matcher reads it. +/// +/// Types come from `selector_type`, which expands a tuple to its components so +/// the hash covers the real shape. It does not normalize the `uint`/`int` +/// aliases, so an ABI declaring `uint` rather than `uint256` yields a signature +/// that hashes to the wrong topic0 and matches no log. graph-node's matcher +/// builds its comparison string the same way, so such a manifest still +/// validates. +pub fn event_signature_with_indexed(event: &Event) -> String { + let params: Vec = event + .inputs + .iter() + .map(|input| { + let ty = input.selector_type(); + if input.indexed { + format!("indexed {}", ty) + } else { + ty.into_owned() + } + }) + .collect(); + + format!("{}({})", event.name, params.join(",")) +} /// Normalize ABI JSON to extract the actual ABI array from various artifact formats. +pub fn normalize_abi_json(abi_str: &str) -> Result { + let value: Value = serde_json::from_str(abi_str).context("Failed to parse ABI JSON")?; + normalize_abi_value(value) +} + +/// Extract the bare ABI array from a parsed value, unwrapping artifact formats. /// /// Supports: /// - Raw ABI array: `[{...}]` /// - Foundry/Hardhat format: `{"abi": [...], ...}` /// - Truffle format: `{"compilerOutput": {"abi": [...], ...}, ...}` -pub fn normalize_abi_json(abi_str: &str) -> Result { - let value: serde_json::Value = - serde_json::from_str(abi_str).context("Failed to parse ABI JSON")?; - +pub fn normalize_abi_value(value: Value) -> Result { // Case 1: Already an array - return as-is if value.is_array() { return Ok(value); @@ -40,8 +113,66 @@ pub fn normalize_abi_json(abi_str: &str) -> Result { )) } +/// Normalize a parsed ABI value and add the defaults alloy's parser requires: +/// - `anonymous: false` on events (alloy requires the field) +/// - `param{index}` names for unnamed top-level event parameters (matches +/// graph-cli, so the generated getters and manifest signature agree) +pub fn preprocess_abi_value(value: Value) -> Result { + let mut abi = normalize_abi_value(value)?; + + if let Some(items) = abi.as_array_mut() { + for item in items { + if let Some(obj) = item.as_object_mut() { + let is_event = obj.get("type").and_then(|t| t.as_str()) == Some("event"); + if is_event { + if !obj.contains_key("anonymous") { + obj.insert("anonymous".to_string(), Value::Bool(false)); + } + if let Some(inputs) = obj.get_mut("inputs") { + add_default_event_param_names(inputs); + } + } + } + } + } + + Ok(abi) +} + +/// Normalize and preprocess ABI JSON, returning the serialized array string that +/// alloy's `JsonAbi` parser accepts. +pub fn preprocess_abi_json(abi_str: &str) -> Result { + let value: Value = serde_json::from_str(abi_str).context("Failed to parse ABI JSON")?; + let abi = preprocess_abi_value(value)?; + serde_json::to_string(&abi).context("Failed to serialize processed ABI") +} + +/// Add `param{index}` names to unnamed event parameters to match graph-cli. +/// +/// An unnamed param reaches us two ways: solc emits `"name": ""` (the key is +/// always present), while a hand-written ABI may omit the key entirely. Both +/// count as unnamed. The index matches the one the codegen counts from, so the +/// generated getter and this name agree. +fn add_default_event_param_names(params: &mut Value) { + if let Some(params_arr) = params.as_array_mut() { + for (index, param) in params_arr.iter_mut().enumerate() { + if let Some(obj) = param.as_object_mut() { + let unnamed = obj + .get("name") + .and_then(|n| n.as_str()) + .is_none_or(|name| name.is_empty()); + if unnamed { + obj.insert("name".to_string(), Value::String(format!("param{}", index))); + } + } + } + } +} + #[cfg(test)] mod tests { + use serde_json::json; + use super::*; #[test] @@ -98,4 +229,14 @@ mod tests { .contains("Invalid ABI format") ); } + + #[test] + fn test_is_hashed_when_indexed_tolerates_unparseable_type() { + // alloy accepts a component-less `tuple`, whose selector type is the + // bare word "tuple" and does not parse as a Solidity type. Scaffolding + // must not abort on an ABI that parsed. + let param: EventParam = + serde_json::from_value(json!({"name": "x", "type": "tuple", "indexed": true})).unwrap(); + assert!(!is_hashed_when_indexed(¶m)); + } } diff --git a/gnd/src/codegen/abi.rs b/gnd/src/codegen/abi.rs index 9b881fdcbe9..2ae554599ea 100644 --- a/gnd/src/codegen/abi.rs +++ b/gnd/src/codegen/abi.rs @@ -13,6 +13,7 @@ use graph::abi::{ use regex::Regex; use super::typescript::{self as ts, Class, ClassMember, Method, ModuleImports, Param as TsParam}; +use crate::abi::{indexed_input_type, resolve_event_param_type}; use crate::shared::{capitalize, handle_reserved_word}; /// Resolve a `Param`'s type to `DynSolType`. @@ -23,14 +24,6 @@ fn resolve_param_type(param: &Param) -> DynSolType { .expect("valid ABI type") } -/// Resolve an `EventParam`'s type to `DynSolType`. -fn resolve_event_param_type(param: &EventParam) -> DynSolType { - param - .selector_type() - .parse::() - .expect("valid ABI type") -} - const GRAPH_TS_MODULE: &str = "@graphprotocol/graph-ts"; /// ABI code generator. @@ -1363,16 +1356,6 @@ fn is_tuple_matrix_type(param_type: &DynSolType) -> bool { } } -/// Handle indexed input type conversion. -fn indexed_input_type(param_type: &DynSolType) -> DynSolType { - // Strings, bytes, and arrays are encoded and hashed to bytes32 - match param_type { - DynSolType::String | DynSolType::Bytes | DynSolType::Tuple(_) => DynSolType::FixedBytes(32), - DynSolType::Array(_) | DynSolType::FixedArray(_, _) => DynSolType::FixedBytes(32), - _ => param_type.clone(), - } -} - #[cfg(test)] mod tests { diff --git a/gnd/src/commands/codegen.rs b/gnd/src/commands/codegen.rs index deb0feb0ca5..6ececbfc504 100644 --- a/gnd/src/commands/codegen.rs +++ b/gnd/src/commands/codegen.rs @@ -15,7 +15,7 @@ use graph::abi::JsonAbi; use graphql_tools::parser::schema as gql; use semver::Version; -use crate::abi::normalize_abi_json; +use crate::abi::preprocess_abi_json; use crate::codegen::{ AbiCodeGenerator, Class, GENERATED_FILE_NOTE, ModuleImports, SchemaCodeGenerator, Template as CodegenTemplate, TemplateCodeGenerator, TemplateKind, @@ -237,58 +237,6 @@ fn generate_schema_types( Ok(true) } -/// Preprocess ABI JSON to normalize artifact formats and add defaults -/// required by alloy's ABI parser: -/// - `anonymous: false` for events (alloy requires this field) -/// - `param{index}` names for unnamed event parameters (to match graph-cli behavior) -fn preprocess_abi_json(abi_str: &str) -> Result { - // Normalize to get the ABI array from various artifact formats - let mut abi = normalize_abi_json(abi_str)?; - - if let Some(items) = abi.as_array_mut() { - for item in items { - if let Some(obj) = item.as_object_mut() { - let is_event = obj - .get("type") - .and_then(|t| t.as_str()) - .map(|t| t == "event") - .unwrap_or(false); - - if is_event { - // Add anonymous: false for events if missing (alloy requires it) - if !obj.contains_key("anonymous") { - obj.insert("anonymous".to_string(), serde_json::Value::Bool(false)); - } - - // Add param{index} names for unnamed event parameters - if let Some(inputs) = obj.get_mut("inputs") { - add_default_event_param_names(inputs); - } - } - } - } - } - - serde_json::to_string(&abi).context("Failed to serialize processed ABI") -} - -/// Add `param{index}` names to unnamed event parameters to match graph-cli behavior. -/// Alloy defaults missing names to empty strings, but for events we want `param0`, `param1`, etc. -fn add_default_event_param_names(params: &mut serde_json::Value) { - if let Some(params_arr) = params.as_array_mut() { - for (index, param) in params_arr.iter_mut().enumerate() { - if let Some(obj) = param.as_object_mut() - && !obj.contains_key("name") - { - obj.insert( - "name".to_string(), - serde_json::Value::String(format!("param{}", index)), - ); - } - } - } -} - /// Generate types from an ABI file. fn generate_abi_types(name: &str, abi_path: &Path, output_dir: &Path) -> Result<()> { step(Step::Load, &format!("Load ABI from {}", abi_path.display())); diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index b0b731ff4be..d8d433776ca 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; +use graph::abi::{Event, EventParam, Param}; + use super::ScaffoldOptions; use crate::shared::handle_reserved_word; @@ -97,7 +99,7 @@ fn get_entities(events: &[ResolvedEvent]) -> String { pub struct EventInfo { pub name: String, pub signature: String, - pub inputs: Vec, + pub inputs: Vec, } /// An event resolved to the concrete names the generators render. @@ -149,7 +151,7 @@ pub fn disambiguate_events(events: Vec) -> Vec { /// ABI codegen gives the generated getters: reserved words are escaped and /// unnamed params become `param`, with a counter for any collisions. /// Keeps the mapping's right-hand side in sync with the generated bindings. -pub fn event_param_accessors(inputs: &[EventInput]) -> Vec { +pub fn event_param_accessors(inputs: &[EventParam]) -> Vec { let mut seen: HashMap = HashMap::new(); inputs .iter() @@ -172,21 +174,95 @@ pub fn event_param_accessors(inputs: &[EventInput]) -> Vec { .collect() } -/// Event input parameter. -#[derive(Debug, Clone)] -pub struct EventInput { - pub name: String, +/// 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, - pub indexed: bool, } -/// Extract events from ABI JSON. +/// Flatten an event's inputs into leaves, unrolling a single `tuple` into its +/// components (`tuple[]` stays one leaf). 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. +/// +/// An indexed reference type is reduced to a `bytes32` hash by the EVM, so the +/// binding getter yields `Bytes` and there is nothing to unroll: it becomes a +/// single hash leaf. +pub fn flatten_event_inputs(inputs: &[EventParam]) -> Vec { + let accessors = event_param_accessors(inputs); + let mut leaves = Vec::new(); + for (input, accessor) in inputs.iter().zip(&accessors) { + if crate::abi::is_hashed_when_indexed(input) { + leaves.push(InputLeaf { + field: super::sanitize_field_name(&input.name), + accessor: accessor.clone(), + solidity_type: "bytes32".to_string(), + }); + continue; + } + flatten_into( + &mut leaves, + std::slice::from_ref(accessor), + &[super::sanitize_field_name(&input.name)], + &input.ty, + &input.components, + ); + } + leaves +} + +fn flatten_into( + out: &mut Vec, + accessor_path: &[String], + field_path: &[String], + solidity_type: &str, + components: &[Param], +) { + if solidity_type != "tuple" { + out.push(InputLeaf { + field: field_path.join("_"), + accessor: accessor_path.join("."), + solidity_type: solidity_type.to_string(), + }); + return; + } + + for (i, comp) in 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_into(out, &accessor, &fields, &comp.ty, &comp.components); + } +} + +/// Extract events from the ABI, in file order. +/// +/// The ABI is preprocessed (artifact formats unwrapped, `anonymous` and +/// `param{index}` defaults added) so alloy can parse each event item into a +/// typed `Event`. A malformed event is skipped with a warning. pub fn extract_events_from_abi(options: &ScaffoldOptions) -> Vec { let Some(abi) = &options.abi else { return vec![]; }; - let Some(items) = abi.as_array() else { + let normalized = match crate::abi::preprocess_abi_value(abi.clone()) { + Ok(value) => value, + Err(err) => { + eprintln!("warning: could not parse ABI for scaffolding: {err}"); + return vec![]; + } + }; + + let Some(items) = normalized.as_array() else { return vec![]; }; @@ -197,60 +273,28 @@ pub fn extract_events_from_abi(options: &ScaffoldOptions) -> Vec { continue; } - let Some(name) = item.get("name").and_then(|n| n.as_str()) else { - continue; - }; - - 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::>() - }) - .unwrap_or_default(); - - let signature = format_event_signature(name, &inputs); - - events.push(EventInfo { - name: name.to_string(), - signature, - inputs, - }); + match serde_json::from_value::(item.clone()) { + Ok(event) => { + let signature = crate::abi::event_signature_with_indexed(&event); + events.push(EventInfo { + name: event.name, + signature, + inputs: event.inputs, + }); + } + Err(err) => { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(""); + eprintln!("warning: skipping malformed event `{name}` in ABI: {err}"); + } + } } 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(); - - format!("{}({})", name, params.join(",")) -} - #[cfg(test)] mod tests { use super::*; @@ -379,22 +423,109 @@ mod tests { } #[test] - fn test_format_event_signature() { - let inputs = vec![ - EventInput { - name: "from".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "value".to_string(), - solidity_type: "uint256".to_string(), - indexed: false, - }, - ]; + fn test_extract_events_accepts_artifact_format() { + // A Foundry/Hardhat artifact wrapper is unwrapped before parsing. + let abi = json!({ + "abi": [ + { + "type": "event", + "name": "Transfer", + "inputs": [{"name": "from", "type": "address", "indexed": true}] + } + ] + }); + let options = ScaffoldOptions { + abi: Some(abi), + ..Default::default() + }; + let events = extract_events_from_abi(&options); + assert_eq!(events.len(), 1); + assert_eq!(events[0].name, "Transfer"); + } - let sig = format_event_signature("Transfer", &inputs); - assert_eq!(sig, "Transfer(indexed address,uint256)"); + #[test] + fn test_extract_events_names_unnamed_params() { + // Unnamed event params are named `param{index}`, matching graph-cli, so + // the accessor and the generated getter agree. solc writes `"name": ""` + // for these; a hand-written ABI may omit the key. Both are unnamed. + let abi = json!([ + { + "type": "event", + "name": "Act", + "anonymous": false, + "inputs": [ + {"name": "", "type": "uint256", "indexed": false, "internalType": "uint256"}, + {"name": "", "type": "address", "indexed": false, "internalType": "address"}, + {"type": "bool"} + ] + } + ]); + let options = ScaffoldOptions { + abi: Some(abi), + ..Default::default() + }; + let events = extract_events_from_abi(&options); + assert_eq!(events.len(), 1); + assert_eq!(events[0].inputs[0].name, "param0"); + assert_eq!(events[0].inputs[1].name, "param1"); + assert_eq!(events[0].inputs[2].name, "param2"); + } + + #[test] + fn test_generate_schema_unnamed_params_do_not_collide() { + // Two unnamed params must not both sanitize to the field `value`: a + // duplicate field is not valid GraphQL and fails the build. + let abi = json!([ + { + "type": "event", + "name": "Act", + "anonymous": false, + "inputs": [ + {"name": "", "type": "address", "indexed": false}, + {"name": "", "type": "uint8", "indexed": false} + ] + } + ]); + let options = ScaffoldOptions { + abi: Some(abi), + index_events: true, + ..Default::default() + }; + let schema = super::super::generate_schema(&options); + assert!(schema.contains("param0: Bytes!"), "{}", schema); + assert!(schema.contains("param1: Int!"), "{}", schema); + assert!(!schema.contains("value:"), "{}", schema); + } + + #[test] + fn test_extract_events_expands_tuple_signature() { + // A tuple param expands to its components so the topic0 hash matches the + // on-chain event; array suffixes are preserved. + let abi = json!([ + { + "type": "event", + "name": "Filled", + "inputs": [ + {"name": "order", "type": "tuple", "components": [ + {"name": "maker", "type": "address"}, + {"name": "price", "type": "uint256"} + ]}, + {"name": "fills", "type": "tuple[]", "components": [ + {"name": "amount", "type": "uint256"} + ]}, + {"name": "fee", "type": "uint256"} + ] + } + ]); + let options = ScaffoldOptions { + abi: Some(abi), + ..Default::default() + }; + let events = extract_events_from_abi(&options); + assert_eq!( + events[0].signature, + "Filled((address,uint256),(uint256)[],uint256)" + ); } #[test] diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index 8749c91ab2b..af4e97ad566 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -201,16 +201,21 @@ 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). + // 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 +234,18 @@ fn generate_single_handler(resolved: &ResolvedEvent) -> String { ) } +/// Whether a leaf is an `address[]`, whose binding type `Array
` needs a +/// `changetype` to fit a `Bytes[]` entity field. +/// +/// Only `address` qualifies: `Address extends Bytes`, so the cast is an upcast. +/// `ethereum.Tuple` extends `Array` and is not a `Bytes`, so casting a +/// `tuple[]` would be an unchecked reinterpret that compiles and then writes +/// heap pointers into the entity. It has no `Bytes[]` form, so it gets no cast +/// and fails to compile instead. +fn needs_bytes_array_cast(solidity_type: &str) -> bool { + solidity_type == "address[]" +} + /// Extract callable functions from ABI for documentation comments. fn extract_callable_functions(options: &ScaffoldOptions) -> String { let Some(abi) = &options.abi else { @@ -435,4 +452,156 @@ 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 + ); + } + + #[test] + fn test_generate_mapping_tuple_array_is_not_cast_to_bytes() { + // Casting Array to Bytes[] is an unchecked reinterpret: it + // compiles and writes heap pointers into the entity. + let abi = json!([ + { + "type": "event", + "name": "Filled", + "inputs": [ + {"name": "fills", "type": "tuple[]", "components": [ + {"name": "account", "type": "address"}, + {"name": "amount", "type": "uint256"} + ]} + ] + } + ]); + + let options = ScaffoldOptions { + contract_name: "Vault".to_string(), + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let mapping = generate_mapping(&options); + assert!( + !mapping.contains("changetype"), + "tuple[] must never be changetype'd to Bytes[], got:\n{}", + mapping + ); + } + + #[test] + fn test_generate_mapping_indexed_reference_params_are_hashes() { + // Indexed reference types are keccak'd into a topic, so the binding + // getter yields Bytes: there is no tuple to unroll and no array to cast. + let abi = json!([ + { + "type": "event", + "name": "Deposit", + "inputs": [ + {"name": "data", "type": "tuple", "indexed": true, "components": [ + {"name": "account", "type": "address"} + ]}, + {"name": "owners", "type": "address[]", "indexed": true}, + {"name": "amount", "type": "uint256", "indexed": true} + ] + } + ]); + + let options = ScaffoldOptions { + contract_name: "Vault".to_string(), + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let mapping = generate_mapping(&options); + + // The tuple is a hash: read it whole, never `event.params.data.account` + // (which does not exist on Bytes). + assert!( + mapping.contains("entity.data = event.params.data"), + "{}", + mapping + ); + assert!(!mapping.contains("data.account"), "{}", mapping); + + // The array is a hash too. changetype is an unchecked reinterpret, so + // casting a hash to Bytes[] would compile and yield garbage at runtime. + assert!( + mapping.contains("entity.owners = event.params.owners"), + "{}", + mapping + ); + assert!(!mapping.contains("changetype"), "{}", mapping); + + // An indexed value type still fits in a topic and is unaffected. + assert!( + mapping.contains("entity.amount = event.params.amount"), + "{}", + mapping + ); + } + + #[test] + fn test_generate_schema_indexed_reference_params_are_bytes() { + let abi = json!([ + { + "type": "event", + "name": "Deposit", + "inputs": [ + {"name": "data", "type": "tuple", "indexed": true, "components": [ + {"name": "account", "type": "address"}, + {"name": "amount", "type": "uint256"} + ]}, + {"name": "owners", "type": "address[]", "indexed": true} + ] + } + ]); + + let options = ScaffoldOptions { + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let schema = crate::scaffold::generate_schema(&options); + // Only the hash exists on-chain, so each is a single Bytes field. + assert!(schema.contains("data: Bytes!"), "{}", schema); + assert!(schema.contains("owners: Bytes!"), "{}", schema); + // The components are not in the log; they must not become fields. + assert!(!schema.contains("data_account"), "{}", schema); + } } diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 72cb5d70a37..0ad8e618e42 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, 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 ed47de3e041..541dc401858 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -1,7 +1,9 @@ //! Schema (schema.graphql) generation for scaffold. +use graph::abi::EventParam; + use super::ScaffoldOptions; -use super::manifest::{EventInput, extract_events_from_abi}; +use super::manifest::extract_events_from_abi; use super::sanitize_field_name; /// Generate the schema.graphql content. @@ -32,7 +34,7 @@ pub fn generate_schema(options: &ScaffoldOptions) -> String { /// Generate an example entity for placeholder mode. /// Uses first 2 event params if available, with type comments. -fn generate_example_entity(inputs: &[EventInput]) -> String { +fn generate_example_entity(inputs: &[EventParam]) -> String { let mut fields = String::new(); fields.push_str(" # Use Bytes when possible for better performance\n"); fields.push_str(" id: Bytes!\n"); @@ -41,10 +43,10 @@ fn generate_example_entity(inputs: &[EventInput]) -> String { // Include first 2 event params with type comments for input in inputs.iter().take(2) { let field_name = sanitize_field_name(&input.name); - let graphql_type = solidity_to_graphql(&input.solidity_type); + let graphql_type = solidity_to_graphql(&input.ty); fields.push_str(&format!( " {}: {}! # {}\n", - field_name, graphql_type, input.solidity_type + field_name, graphql_type, input.ty )); } @@ -56,17 +58,16 @@ fn generate_example_entity(inputs: &[EventInput]) -> String { } /// Generate an entity type for an event. -pub fn generate_event_entity(entity_name: &str, inputs: &[EventInput]) -> String { +pub fn generate_event_entity(entity_name: &str, inputs: &[EventParam]) -> String { let mut fields = String::new(); // 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 @@ -164,22 +165,16 @@ mod tests { #[test] fn test_generate_example_entity_with_inputs() { + let param = |name: &str, ty: &str, indexed: bool| EventParam { + name: name.to_string(), + ty: ty.to_string(), + indexed, + ..Default::default() + }; let inputs = vec![ - EventInput { - name: "from".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "to".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "value".to_string(), - solidity_type: "uint256".to_string(), - indexed: false, - }, + param("from", "address", true), + param("to", "address", true), + param("value", "uint256", false), ]; let schema = generate_example_entity(&inputs); @@ -277,4 +272,33 @@ 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); + // Tuple components become one field each, joined with `_`. + assert!(schema.contains("data_account: Bytes!"), "{}", schema); + assert!(schema.contains("data_amount: BigInt!"), "{}", schema); + // The tuple itself is not emitted as a single field. + assert!(!schema.contains("data: "), "{}", schema); + } } diff --git a/gnd/src/validation/mod.rs b/gnd/src/validation/mod.rs index 8991df4fba5..e53bdfe6530 100644 --- a/gnd/src/validation/mod.rs +++ b/gnd/src/validation/mod.rs @@ -16,6 +16,7 @@ use graph::prelude::DeploymentHash; use graph::schema::{InputSchema, SchemaValidationError}; use semver::Version; +use crate::abi::event_signature_with_indexed; use crate::manifest::{Abi, BlockHandlerFilterKind, CallHandler, DataSource, Manifest, Template}; /// Validate a GraphQL schema using graph-node's InputSchema validation. @@ -829,23 +830,6 @@ fn load_abi(abi_path: &Path) -> Option { serde_json::from_str(&json_str).ok() } -/// Build the event signature with `indexed` hints: `Name(indexed type1,type2,...)`. -fn event_signature_with_indexed(event: &Event) -> String { - let params: Vec = event - .inputs - .iter() - .map(|p| { - let ty = p.selector_type(); - if p.indexed { - format!("indexed {}", ty) - } else { - ty.into_owned() - } - }) - .collect(); - format!("{}({})", event.name, params.join(",")) -} - /// Validate that each event handler's event signature matches an event in the ABI. /// /// Matching follows the same logic as graph-node's `contract_event_with_signature`: diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index 192d201c3be..0a49c1f468c 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -651,6 +651,157 @@ 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"); + + // One event carrying a non-indexed tuple (fully in `data`, so unrollable) + // alongside an indexed tuple and an indexed array (keccak'd into a topic, so + // only a hash survives). + 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"} + ]}, + {"name":"key","type":"tuple","indexed":true,"components":[ + {"name":"account","type":"address"} + ]}, + {"name":"owners","type":"address[]","indexed":true} + ]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + tuple_abi.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "Vault", + "--index-events", + "--skip-install", + "--skip-git", + "tuple-init", + ], + temp_dir.path(), + ); + run_gnd_success(&["codegen"], &subgraph_dir); + + let schema = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + let mapping = fs::read_to_string(subgraph_dir.join("src").join("vault.ts")).unwrap(); + let bindings = fs::read_to_string( + subgraph_dir + .join("generated") + .join("Vault") + .join("Vault.ts"), + ) + .unwrap(); + + // The non-indexed tuple is unrolled into one field per component, read via + // the nested accessor that codegen's tuple class actually exposes. + assert!( + schema.contains("data_account: Bytes!") && schema.contains("data_amount: BigInt!"), + "tuple should unroll into schema fields, got:\n{}", + schema + ); + assert!( + mapping.contains("event.params.data.account"), + "mapping should use the nested tuple accessor, got:\n{}", + mapping + ); + + // The indexed params are only hashes on-chain: codegen types both getters + // Bytes, so they must be read whole, with no unroll and no changetype. + assert!( + bindings.contains("get key(): Bytes") && bindings.contains("get owners(): Bytes"), + "indexed reference getters should be Bytes\nbindings:\n{}", + bindings + ); + assert!( + schema.contains("key: Bytes!") && schema.contains("owners: Bytes!"), + "indexed reference params should each be a single Bytes field, got:\n{}", + schema + ); + assert!( + mapping.contains("entity.key = event.params.key") + && mapping.contains("entity.owners = event.params.owners"), + "indexed reference params should be read whole, got:\n{}", + mapping + ); + assert!( + !mapping.contains("key.account") && !mapping.contains("changetype"), + "indexed reference params must not be unrolled or cast, got:\n{}", + mapping + ); +} + +#[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();