From dfb80a7d75c0ae8f082df69940aab736e7512f47 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 12:05:44 +0530 Subject: [PATCH 01/11] gnd: Unify subgraph scaffolding generators Collapse the duplicated code generation so init and add share one path, removing the forked add mapping generator that had drifted from the scaffold one: - a single sanitize_field_name in scaffold/naming.rs (three copies removed) - a single generate_event_handlers driven by a new ResolvedEvent model (passthrough for now); add's copy is deleted - shared SPEC_VERSION / MAPPING_API_VERSION constants and one to_kebab_case No behavior change: init output is byte-identical and add is unchanged. --- gnd/src/commands/add.rs | 199 +++-------------------------------- gnd/src/scaffold/manifest.rs | 38 ++++++- gnd/src/scaffold/mapping.rs | 88 +++++----------- gnd/src/scaffold/mod.rs | 12 ++- gnd/src/scaffold/naming.rs | 69 ++++++++++++ gnd/src/scaffold/schema.rs | 56 +--------- 6 files changed, 155 insertions(+), 307 deletions(-) create mode 100644 gnd/src/scaffold/naming.rs diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index 280253d5509..b69588fabb8 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -8,14 +8,15 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::Parser; -use inflector::Inflector; use serde_json::Value as JsonValue; use crate::config::networks::update_networks_file; use crate::formatter::format_typescript; use crate::output::{Step, step}; -use crate::scaffold::ScaffoldOptions; use crate::scaffold::manifest::{EventInfo, extract_events_from_abi}; +use crate::scaffold::{ + MAPPING_API_VERSION, ResolvedEvent, ScaffoldOptions, generate_event_handlers, to_kebab_case, +}; use crate::services::ContractService; #[derive(Clone, Debug, Parser)] @@ -232,41 +233,12 @@ fn add_abi_file(project_dir: &Path, contract_name: &str, abi: &JsonValue) -> Res Ok(()) } -/// Sanitize a field name for GraphQL. -fn sanitize_field_name(name: &str) -> String { - if name.is_empty() { - return "value".to_string(); - } - - let mut result = name.to_string(); - - // Convert to camelCase if starts with uppercase - if result - .chars() - .next() - .map(|c| c.is_uppercase()) - .unwrap_or(false) - { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - result = first.to_lowercase().collect::() + chars.as_str(); - } - } - - // Avoid reserved words - match result.as_str() { - "id" => "eventId".to_string(), - "type" => "eventType".to_string(), - _ => result, - } -} - /// Add mapping file for the new data source. fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo]) -> Result<()> { let src_dir = project_dir.join("src"); fs::create_dir_all(&src_dir).context("Failed to create src directory")?; - let mapping_file = src_dir.join(format!("{}.ts", contract_name.to_kebab_case())); + let mapping_file = src_dir.join(format!("{}.ts", to_kebab_case(contract_name))); if mapping_file.exists() { step( @@ -276,7 +248,12 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo return Ok(()); } - let mapping_content = generate_mapping(contract_name, events); + let resolved: Vec = events + .iter() + .cloned() + .map(ResolvedEvent::passthrough) + .collect(); + let mapping_content = generate_event_handlers(contract_name, &resolved); let formatted = format_typescript(&mapping_content).unwrap_or(mapping_content); step( @@ -288,76 +265,6 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo Ok(()) } -/// Generate mapping handlers for events. -fn generate_mapping(contract_name: &str, events: &[EventInfo]) -> String { - let mut imports = String::new(); - let mut handlers = String::new(); - - imports.push_str("import { BigInt, Bytes } from \"@graphprotocol/graph-ts\"\n"); - - if events.is_empty() { - return imports; - } - - // Import event types - let event_imports: Vec = events - .iter() - .map(|e| format!("{} as {}Event", e.name, e.name)) - .collect(); - - imports.push_str(&format!( - "import {{ {} }} from \"../generated/{}/{}\"\n", - event_imports.join(", "), - contract_name, - contract_name - )); - - // Import entity types - let entity_imports: Vec = events.iter().map(|e| e.name.clone()).collect(); - - imports.push_str(&format!( - "import {{ {} }} from \"../generated/schema\"\n", - entity_imports.join(", ") - )); - - // Generate handler for each event - for event in events { - handlers.push('\n'); - handlers.push_str(&generate_event_handler(event)); - } - - format!("{}\n{}", imports, handlers) -} - -/// Generate a handler function for an event. -fn generate_event_handler(event: &EventInfo) -> String { - let event_name = &event.name; - - let mut field_assignments = String::new(); - for input in &event.inputs { - let field_name = sanitize_field_name(&input.name); - field_assignments.push_str(&format!( - " entity.{} = event.params.{}\n", - field_name, input.name - )); - } - - format!( - r#"export function handle{event_name}(event: {event_name}Event): void {{ - let entity = new {event_name}( - event.transaction.hash.concatI32(event.logIndex.toI32()) - ) - -{field_assignments} entity.blockNumber = event.block.number - entity.blockTimestamp = event.block.timestamp - entity.transactionHash = event.transaction.hash - - entity.save() -}} -"# - ) -} - /// Update the manifest with the new data source. fn update_manifest( manifest_path: &Path, @@ -429,7 +336,7 @@ fn update_manifest( ); mapping.insert( serde_yaml::Value::String("apiVersion".to_string()), - serde_yaml::Value::String("0.0.9".to_string()), + serde_yaml::Value::String(MAPPING_API_VERSION.to_string()), ); mapping.insert( serde_yaml::Value::String("language".to_string()), @@ -449,7 +356,7 @@ fn update_manifest( ); mapping.insert( serde_yaml::Value::String("file".to_string()), - serde_yaml::Value::String(format!("./src/{}.ts", contract_name.to_kebab_case())), + serde_yaml::Value::String(format!("./src/{}.ts", to_kebab_case(contract_name))), ); // Build the data source @@ -494,7 +401,6 @@ fn update_manifest( #[cfg(test)] mod tests { use super::*; - use crate::scaffold::manifest::{EventInfo, EventInput}; #[tokio::test] async fn test_invalid_address() { @@ -519,87 +425,6 @@ mod tests { ); } - #[test] - fn test_sanitize_field_name() { - assert_eq!(sanitize_field_name("owner"), "owner"); - assert_eq!(sanitize_field_name("Owner"), "owner"); - assert_eq!(sanitize_field_name(""), "value"); - // Reserved words - assert_eq!(sanitize_field_name("id"), "eventId"); - assert_eq!(sanitize_field_name("type"), "eventType"); - } - - #[test] - fn test_to_kebab_case() { - assert_eq!("MyContract".to_kebab_case(), "my-contract"); - assert_eq!("SimpleToken".to_kebab_case(), "simple-token"); - assert_eq!("Contract".to_kebab_case(), "contract"); - assert_eq!("contract".to_kebab_case(), "contract"); - assert_eq!("ERC20Token".to_kebab_case(), "erc20-token"); - } - - #[test] - fn test_generate_event_handler() { - let event = EventInfo { - name: "Approval".to_string(), - signature: "Approval(address,address,uint256)".to_string(), - inputs: vec![ - EventInput { - name: "owner".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "spender".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "value".to_string(), - solidity_type: "uint256".to_string(), - indexed: false, - }, - ], - }; - - let handler = generate_event_handler(&event); - assert!(handler.contains("export function handleApproval")); - assert!(handler.contains("event: ApprovalEvent")); - assert!(handler.contains("new Approval(")); - assert!(handler.contains("entity.owner = event.params.owner")); - assert!(handler.contains("entity.spender = event.params.spender")); - assert!(handler.contains("entity.value = event.params.value")); - assert!(handler.contains("entity.save()")); - } - - #[test] - fn test_generate_mapping() { - let events = vec![EventInfo { - name: "Transfer".to_string(), - signature: "Transfer(address,address,uint256)".to_string(), - inputs: vec![EventInput { - name: "from".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }], - }]; - - let mapping = generate_mapping("Token", &events); - assert!(mapping.contains("import { BigInt, Bytes }")); - assert!(mapping.contains("Transfer as TransferEvent")); - assert!(mapping.contains("../generated/Token/Token")); - assert!(mapping.contains("import { Transfer }")); - assert!(mapping.contains("../generated/schema")); - } - - #[test] - fn test_generate_mapping_empty_events() { - let events: Vec = vec![]; - let mapping = generate_mapping("Empty", &events); - assert!(mapping.contains("import { BigInt, Bytes }")); - assert!(!mapping.contains("export function handle")); - } - #[tokio::test] async fn test_missing_manifest() { let opt = AddOpt { diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index 5e1affb7673..eea9c21b2c8 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -25,7 +25,7 @@ pub fn generate_manifest(options: &ScaffoldOptions) -> String { let event_handlers = get_event_handlers(options); format!( - r#"specVersion: 1.3.0 + r#"specVersion: {spec_version} indexerHints: prune: auto schema: @@ -37,7 +37,7 @@ dataSources: source: {source} mapping: kind: ethereum/events - apiVersion: 0.0.9 + apiVersion: {api_version} language: wasm/assemblyscript entities:{entities} abis: @@ -46,6 +46,8 @@ dataSources: eventHandlers:{event_handlers} file: {mapping_file} "#, + spec_version = super::SPEC_VERSION, + api_version = super::MAPPING_API_VERSION, entities = get_entities(options), ) } @@ -93,13 +95,43 @@ fn get_entities(options: &ScaffoldOptions) -> String { } /// Event info extracted from ABI. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct EventInfo { pub name: String, pub signature: String, pub inputs: Vec, } +/// An event resolved to the concrete names the generators render. +/// +/// Decided once (here / by collision resolution) so the schema, mapping and +/// manifest generators never derive names independently: +/// - `alias` names the handler function and the ABI event-type import; it is +/// disambiguated for events overloaded within a single ABI. +/// - `entity_name` names the GraphQL entity type, the `new` expression and the +/// schema import; it gains a contract prefix when it collides with an entity +/// that already exists in the subgraph. +#[derive(Debug, Clone)] +pub struct ResolvedEvent { + pub event: EventInfo, + pub alias: String, + pub entity_name: String, +} + +impl ResolvedEvent { + /// Resolve an event with no disambiguation or collision handling: all names + /// equal the raw event name. Used where there are no existing entities to + /// collide with (a fresh scaffold). + pub fn passthrough(event: EventInfo) -> Self { + let name = event.name.clone(); + Self { + event, + alias: name.clone(), + entity_name: name, + } + } +} + /// Event input parameter. #[derive(Debug, Clone)] pub struct EventInput { diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index 9034cf4ec2c..e8cf496feb4 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -1,7 +1,8 @@ //! Mapping (AssemblyScript) generation for scaffold. use super::ScaffoldOptions; -use super::manifest::{EventInfo, extract_events_from_abi}; +use super::manifest::{EventInfo, ResolvedEvent, extract_events_from_abi}; +use super::sanitize_field_name; /// Generate the mapping.ts content. pub fn generate_mapping(options: &ScaffoldOptions) -> String { @@ -16,7 +17,8 @@ pub fn generate_mapping(options: &ScaffoldOptions) -> String { return generate_placeholder_mapping(contract_name, &events, options); } - generate_event_handlers(contract_name, &events, options) + let resolved: Vec = events.into_iter().map(ResolvedEvent::passthrough).collect(); + generate_event_handlers(contract_name, &resolved) } /// Generate a fallback mapping when no events are found in ABI. @@ -102,7 +104,7 @@ fn generate_first_placeholder_handler( // Generate field assignments for first 2 event params let mut field_assignments = String::new(); for input in event.inputs.iter().take(2) { - let field_name = sanitize_param_name(&input.name); + let field_name = sanitize_field_name(&input.name); field_assignments.push_str(&format!( " entity.{} = event.params.{}\n", field_name, input.name @@ -153,22 +155,18 @@ export function handle{event_name}(event: {event_name}Event): void {{ ) } -/// Generate event handlers for all events in the ABI. -fn generate_event_handlers( - contract_name: &str, - events: &[super::manifest::EventInfo], - _options: &ScaffoldOptions, -) -> String { - let mut imports = String::new(); - let mut handlers = String::new(); +/// Generate event handlers for all resolved events. +pub fn generate_event_handlers(contract_name: &str, events: &[ResolvedEvent]) -> String { + let mut imports = String::from("import { BigInt, Bytes } from \"@graphprotocol/graph-ts\"\n"); - // Import graph-ts types - imports.push_str("import { BigInt, Bytes } from \"@graphprotocol/graph-ts\"\n"); + if events.is_empty() { + return imports; + } - // Import event types + // Import event types (by ABI alias). let event_imports: Vec = events .iter() - .map(|e| format!("{} as {}Event", e.name, e.name)) + .map(|e| format!("{} as {}Event", e.alias, e.alias)) .collect(); imports.push_str(&format!( @@ -178,15 +176,16 @@ fn generate_event_handlers( contract_name )); - // Import entity types - let entity_imports: Vec = events.iter().map(|e| e.name.clone()).collect(); + // Import entity types. + let entity_imports: Vec = events.iter().map(|e| e.entity_name.clone()).collect(); imports.push_str(&format!( "import {{ {} }} from \"../generated/schema\"\n", entity_imports.join(", ") )); - // Generate handler for each event + // Generate handler for each event. + let mut handlers = String::new(); for event in events { handlers.push('\n'); handlers.push_str(&generate_single_handler(event)); @@ -195,14 +194,15 @@ fn generate_event_handlers( format!("{}\n{}", imports, handlers) } -/// Generate a handler function for a single event. -fn generate_single_handler(event: &super::manifest::EventInfo) -> String { - let event_name = &event.name; +/// Generate a handler function for a single resolved event. +fn generate_single_handler(resolved: &ResolvedEvent) -> String { + let alias = &resolved.alias; + let entity_name = &resolved.entity_name; - // Generate field assignments from event parameters + // Generate field assignments from event parameters. let mut field_assignments = String::new(); - for input in &event.inputs { - let field_name = sanitize_param_name(&input.name); + for input in &resolved.event.inputs { + let field_name = sanitize_field_name(&input.name); field_assignments.push_str(&format!( " entity.{} = event.params.{}\n", field_name, input.name @@ -210,8 +210,8 @@ fn generate_single_handler(event: &super::manifest::EventInfo) -> String { } format!( - r#"export function handle{event_name}(event: {event_name}Event): void {{ - let entity = new {event_name}( + r#"export function handle{alias}(event: {alias}Event): void {{ + let entity = new {entity_name}( event.transaction.hash.concatI32(event.logIndex.toI32()) ) @@ -225,34 +225,6 @@ fn generate_single_handler(event: &super::manifest::EventInfo) -> String { ) } -/// Sanitize parameter name for use in AssemblyScript. -fn sanitize_param_name(name: &str) -> String { - if name.is_empty() { - return "value".to_string(); - } - - // Convert to camelCase if starts with uppercase - let mut result = name.to_string(); - if result - .chars() - .next() - .map(|c| c.is_uppercase()) - .unwrap_or(false) - { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - result = first.to_lowercase().collect::() + chars.as_str(); - } - } - - // Avoid reserved words - match result.as_str() { - "id" => "eventId".to_string(), - "type" => "eventType".to_string(), - _ => result, - } -} - /// Extract callable functions from ABI for documentation comments. fn extract_callable_functions(options: &ScaffoldOptions) -> String { let Some(abi) = &options.abi else { @@ -394,14 +366,6 @@ mod tests { assert!(mapping.contains("event.params.value")); } - #[test] - fn test_sanitize_param_name() { - assert_eq!(sanitize_param_name("from"), "from"); - assert_eq!(sanitize_param_name("TokenId"), "tokenId"); - assert_eq!(sanitize_param_name("id"), "eventId"); - assert_eq!(sanitize_param_name(""), "value"); - } - #[test] fn test_extract_callable_functions() { let abi = json!([ diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 2a9e7f18d39..2c5faa7fcc8 100644 --- a/gnd/src/scaffold/mod.rs +++ b/gnd/src/scaffold/mod.rs @@ -5,10 +5,14 @@ pub mod manifest; mod mapping; +mod naming; mod schema; -pub use manifest::{EventInfo, EventInput, extract_events_from_abi, generate_manifest}; -pub use mapping::generate_mapping; +pub use manifest::{ + EventInfo, EventInput, ResolvedEvent, extract_events_from_abi, generate_manifest, +}; +pub use mapping::{generate_event_handlers, generate_mapping}; +pub(crate) use naming::sanitize_field_name; pub use schema::generate_schema; use std::fs; @@ -60,6 +64,10 @@ const GRAPH_CLI_VERSION: &str = "0.98.0"; const GRAPH_TS_VERSION: &str = "0.37.0"; const MATCHSTICK_VERSION: &str = "0.6.0"; +/// Manifest format versions emitted by the scaffolder. +pub(crate) const SPEC_VERSION: &str = "1.3.0"; +pub(crate) const MAPPING_API_VERSION: &str = "0.0.9"; + /// Generate all scaffold files and write to directory. pub fn generate_scaffold(dir: &Path, options: &ScaffoldOptions) -> Result<()> { step(Step::Generate, "Generating scaffold files"); diff --git a/gnd/src/scaffold/naming.rs b/gnd/src/scaffold/naming.rs new file mode 100644 index 00000000000..069e14d9bec --- /dev/null +++ b/gnd/src/scaffold/naming.rs @@ -0,0 +1,69 @@ +//! Shared name sanitization for scaffold code generation. +//! +//! A single source of truth for turning ABI parameter names into valid GraphQL +//! field / AssemblyScript identifiers, used by both the schema and mapping +//! generators so the two never disagree. + +/// Sanitize a parameter name into a valid GraphQL/AssemblyScript field identifier. +pub(crate) fn sanitize_field_name(name: &str) -> String { + if name.is_empty() { + return "value".to_string(); + } + + // Identifiers must start with a letter or underscore; replace anything else. + let mut result = String::new(); + for (i, c) in name.chars().enumerate() { + if i == 0 && c.is_ascii_digit() { + result.push('_'); + } + if c.is_alphanumeric() || c == '_' { + result.push(c); + } else { + result.push('_'); + } + } + + // Convert a leading uppercase to camelCase. + if result + .chars() + .next() + .map(|c| c.is_uppercase()) + .unwrap_or(false) + { + let mut chars = result.chars(); + if let Some(first) = chars.next() { + result = first.to_lowercase().collect::() + chars.as_str(); + } + } + + // Avoid GraphQL reserved field names. + match result.as_str() { + "id" => "eventId".to_string(), + "type" => "eventType".to_string(), + _ => result, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_field_name() { + // Normal names pass through. + assert_eq!(sanitize_field_name("owner"), "owner"); + assert_eq!(sanitize_field_name("from"), "from"); + // Empty -> placeholder. + assert_eq!(sanitize_field_name(""), "value"); + // Leading uppercase -> camelCase. + assert_eq!(sanitize_field_name("Owner"), "owner"); + assert_eq!(sanitize_field_name("TokenId"), "tokenId"); + // Reserved words. + assert_eq!(sanitize_field_name("id"), "eventId"); + assert_eq!(sanitize_field_name("type"), "eventType"); + // Leading digit -> underscore prefix. + assert_eq!(sanitize_field_name("0value"), "_0value"); + // Non-alphanumeric -> underscore. + assert_eq!(sanitize_field_name("a-b"), "a_b"); + } +} diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index b7141f66f3d..f645636332c 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -2,6 +2,7 @@ use super::ScaffoldOptions; use super::manifest::{EventInput, extract_events_from_abi}; +use super::sanitize_field_name; /// Generate the schema.graphql content. pub fn generate_schema(options: &ScaffoldOptions) -> String { @@ -55,7 +56,7 @@ fn generate_example_entity(inputs: &[EventInput]) -> String { } /// Generate an entity type for an event. -fn generate_event_entity(event_name: &str, inputs: &[EventInput]) -> String { +pub fn generate_event_entity(entity_name: &str, inputs: &[EventInput]) -> String { let mut fields = String::new(); // ID field @@ -76,7 +77,7 @@ fn generate_event_entity(event_name: &str, inputs: &[EventInput]) -> String { format!( "# Declare entity types as immutable when possible for better performance\n\ type {} @entity(immutable: true) {{\n{}\n}}", - event_name, fields + entity_name, fields ) } @@ -120,47 +121,6 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { } } -/// Sanitize a field name to be a valid GraphQL identifier. -fn sanitize_field_name(name: &str) -> String { - if name.is_empty() { - return "value".to_string(); - } - - // GraphQL field names must start with a letter or underscore - let mut result = String::new(); - - for (i, c) in name.chars().enumerate() { - if i == 0 && c.is_ascii_digit() { - result.push('_'); - } - if c.is_alphanumeric() || c == '_' { - result.push(c); - } else { - result.push('_'); - } - } - - // Convert to camelCase if starts with uppercase - if result - .chars() - .next() - .map(|c| c.is_uppercase()) - .unwrap_or(false) - { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - result = first.to_lowercase().collect::() + chars.as_str(); - } - } - - // Avoid reserved words - match result.as_str() { - "id" => "eventId".to_string(), - "type" => "eventType".to_string(), - _ => result, - } -} - #[cfg(test)] mod tests { use super::*; @@ -277,14 +237,4 @@ mod tests { assert_eq!(solidity_to_graphql("address[]"), "[Bytes!]"); assert_eq!(solidity_to_graphql("uint256[]"), "[BigInt!]"); } - - #[test] - fn test_sanitize_field_name() { - assert_eq!(sanitize_field_name("from"), "from"); - assert_eq!(sanitize_field_name("tokenId"), "tokenId"); - assert_eq!(sanitize_field_name("TokenId"), "tokenId"); - assert_eq!(sanitize_field_name("123value"), "_123value"); - assert_eq!(sanitize_field_name("id"), "eventId"); - assert_eq!(sanitize_field_name(""), "value"); - } } From 7359d5a060311d810b727b83d5757ba9bf55a673 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 12:13:09 +0530 Subject: [PATCH 02/11] gnd: Write schema entities and add guards in `add` `gnd add` generated mappings and manifest entries that referenced entity types it never declared, so codegen and build failed. Declare them, and tighten two edges: - append an entity type per new event to schema.graphql - error when the data source name already exists - only update networks.json when the file is present --- gnd/src/commands/add.rs | 97 +++++++++++++++++++++++++++++++++------ gnd/src/scaffold/mod.rs | 2 +- gnd/tests/cli_commands.rs | 63 +++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index b69588fabb8..5e4ead7f5a1 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -3,6 +3,7 @@ //! This command adds a new data source to an existing subgraph, generating //! the necessary manifest entries, schema types, and mapping stubs. +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; @@ -15,7 +16,8 @@ use crate::formatter::format_typescript; use crate::output::{Step, step}; use crate::scaffold::manifest::{EventInfo, extract_events_from_abi}; use crate::scaffold::{ - MAPPING_API_VERSION, ResolvedEvent, ScaffoldOptions, generate_event_handlers, to_kebab_case, + MAPPING_API_VERSION, ResolvedEvent, ScaffoldOptions, generate_event_entity, + generate_event_handlers, to_kebab_case, }; use crate::services::ContractService; @@ -98,6 +100,14 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { // Fetch or load ABI let (abi, contract_name, start_block) = get_contract_info(&opt, &network).await?; + // A data source / template name must be unique within the subgraph. + if existing_source_names(&manifest).contains(&contract_name) { + return Err(anyhow!( + "Data source or template named '{}' already exists. Choose a different name with --contract-name.", + contract_name + )); + } + // Get project directory let project_dir = crate::manifest::manifest_dir(&opt.manifest); @@ -131,19 +141,24 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { &events, )?; - // Update networks.json + // Declare the new event entities in the schema so codegen/build succeed. + add_schema_entities(project_dir, &manifest, &events)?; + + // Update networks.json if the subgraph uses one. let networks_path = project_dir.join(&opt.network_file); - update_networks_file( - &networks_path, - &network, - &contract_name, - &opt.address, - start_block, - )?; - step( - Step::Write, - &format!("Updated {}", opt.network_file.display()), - ); + if networks_path.exists() { + update_networks_file( + &networks_path, + &network, + &contract_name, + &opt.address, + start_block, + )?; + step( + Step::Write, + &format!("Updated {}", opt.network_file.display()), + ); + } step(Step::Done, &format!("Added data source: {}", contract_name)); @@ -265,6 +280,62 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo Ok(()) } +/// Collect the names of all existing data sources and templates in the manifest. +fn existing_source_names(manifest: &serde_yaml::Value) -> HashSet { + let mut names = HashSet::new(); + for key in ["dataSources", "templates"] { + if let Some(seq) = manifest.get(key).and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + names.insert(name.to_string()); + } + } + } + } + names +} + +/// Append entity type definitions for the new events to the subgraph schema. +/// +/// The mapping and manifest reference one entity per event; without this the +/// referenced types never exist in schema.graphql and codegen/build fail. +fn add_schema_entities( + project_dir: &Path, + manifest: &serde_yaml::Value, + events: &[EventInfo], +) -> Result<()> { + if events.is_empty() { + return Ok(()); + } + + let schema_file = manifest + .get("schema") + .and_then(|s| s.get("file")) + .and_then(|f| f.as_str()) + .unwrap_or("schema.graphql"); + let schema_path = project_dir.join(schema_file); + + let mut additions = String::new(); + for event in events { + additions.push('\n'); + additions.push_str(&generate_event_entity(&event.name, &event.inputs)); + additions.push('\n'); + } + + step(Step::Write, &format!("Updating {}", schema_path.display())); + + use std::io::Write; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&schema_path) + .with_context(|| format!("Failed to open schema file: {}", schema_path.display()))?; + file.write_all(additions.as_bytes()) + .context("Failed to append entities to schema")?; + + Ok(()) +} + /// Update the manifest with the new data source. fn update_manifest( manifest_path: &Path, diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 2c5faa7fcc8..7b360be218d 100644 --- a/gnd/src/scaffold/mod.rs +++ b/gnd/src/scaffold/mod.rs @@ -13,7 +13,7 @@ pub use manifest::{ }; pub use mapping::{generate_event_handlers, generate_mapping}; pub(crate) use naming::sanitize_field_name; -pub use schema::generate_schema; +pub use schema::{generate_event_entity, generate_schema}; use std::fs; use std::path::Path; diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index 2b4967bfb7b..ba91ddadad8 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -431,6 +431,13 @@ fn test_add_datasource() { "Initial manifest should not have SecondContract" ); + // The added contract's event entity should not exist in the schema yet. + let schema_before = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + !schema_before.contains("type Trigger"), + "Initial schema should not have the Trigger entity" + ); + // Now add another datasource let second_abi_path = test_abis_path().join("LimitedContract.json"); let output = run_gnd( @@ -465,6 +472,62 @@ fn test_add_datasource() { manifest_after.contains("0x2222222222222222222222222222222222222222"), "Updated manifest should have second contract address" ); + + // The new event entity must be declared in the schema so codegen/build work. + let schema_after = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema_after.contains("type Trigger @entity"), + "Updated schema should declare the Trigger entity, got:\n{}", + schema_after + ); +} + +#[test] +fn test_add_duplicate_name_errors() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("dup-test"); + + let abi_path = test_abis_path().join("SimpleContract.json"); + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi_path.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "FirstContract", + "dup-test", + ], + temp_dir.path(), + ); + + // Adding a data source whose name already exists must fail. + let second_abi_path = test_abis_path().join("LimitedContract.json"); + let output = run_gnd( + &[ + "add", + "0x2222222222222222222222222222222222222222", + "--abi", + second_abi_path.to_str().unwrap(), + "--contract-name", + "FirstContract", + ], + &subgraph_dir, + ); + + assert!( + !output.status.success(), + "gnd add should fail when the data source name already exists" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("already exists"), + "error should mention the name already exists, got: {}", + stderr + ); } // ============================================================================ From ebc8f390664ed358d288c8c6e12b55c72ab2cb7b Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 12:23:15 +0530 Subject: [PATCH 03/11] gnd: Resolve event-name collisions and merges in `add` Decide each event's entity name once in a resolution pass, then feed the schema, mapping and manifest generators from it: - disambiguate events overloaded within one ABI (Transfer, Transfer1) - on collision with an existing entity, rename with the contract prefix by default, or under --merge-entities reuse the entity without redeclaring it while still generating the handler, so the new contract's events are indexed instead of silently dropped Wires up the previously-dead --merge-entities flag. --- gnd/src/commands/add.rs | 219 ++++++++++++++++++++++++++++++----- gnd/src/scaffold/manifest.rs | 4 + gnd/tests/cli_commands.rs | 64 +++++++++- 3 files changed, 258 insertions(+), 29 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index 5e4ead7f5a1..af4a021f6fd 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -3,7 +3,7 @@ //! This command adds a new data source to an existing subgraph, generating //! the necessary manifest entries, schema types, and mapping stubs. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -122,14 +122,21 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { index_events: true, // Always index events for add command }; - // Extract events from ABI + // Extract events from the ABI and resolve their names against the entities + // already present in the subgraph (handles overloads and collisions). let events = extract_events_from_abi(&scaffold_options); + let resolved = resolve_events( + events, + &existing_entities(&manifest), + &contract_name, + opt.merge_entities, + )?; // Add ABI file add_abi_file(project_dir, &contract_name, &abi)?; // Add mapping file - add_mapping_file(project_dir, &contract_name, &events)?; + add_mapping_file(project_dir, &contract_name, &resolved)?; // Update manifest update_manifest( @@ -138,11 +145,11 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { &contract_name, &network, start_block, - &events, + &resolved, )?; // Declare the new event entities in the schema so codegen/build succeed. - add_schema_entities(project_dir, &manifest, &events)?; + add_schema_entities(project_dir, &manifest, &resolved)?; // Update networks.json if the subgraph uses one. let networks_path = project_dir.join(&opt.network_file); @@ -249,7 +256,11 @@ fn add_abi_file(project_dir: &Path, contract_name: &str, abi: &JsonValue) -> Res } /// Add mapping file for the new data source. -fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo]) -> Result<()> { +fn add_mapping_file( + project_dir: &Path, + contract_name: &str, + events: &[ResolvedEvent], +) -> Result<()> { let src_dir = project_dir.join("src"); fs::create_dir_all(&src_dir).context("Failed to create src directory")?; @@ -263,12 +274,7 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo return Ok(()); } - let resolved: Vec = events - .iter() - .cloned() - .map(ResolvedEvent::passthrough) - .collect(); - let mapping_content = generate_event_handlers(contract_name, &resolved); + let mapping_content = generate_event_handlers(contract_name, events); let formatted = format_typescript(&mapping_content).unwrap_or(mapping_content); step( @@ -295,6 +301,85 @@ fn existing_source_names(manifest: &serde_yaml::Value) -> HashSet { names } +/// Collect the entity names already declared by existing data sources / templates. +fn existing_entities(manifest: &serde_yaml::Value) -> HashSet { + let mut entities = HashSet::new(); + for key in ["dataSources", "templates"] { + if let Some(seq) = manifest.get(key).and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(list) = item + .get("mapping") + .and_then(|m| m.get("entities")) + .and_then(|e| e.as_sequence()) + { + for entity in list.iter().filter_map(|e| e.as_str()) { + entities.insert(entity.to_string()); + } + } + } + } + } + entities +} + +/// Resolve event names against the entities already present in the subgraph. +/// +/// - Events overloaded within this ABI are disambiguated (`Transfer`, `Transfer1`). +/// - An event whose name collides with an existing entity is either merged into +/// that entity (`merge_entities`: reuse it, keeping the handler so this +/// contract's events are still indexed, but don't redeclare the type) or +/// renamed with the contract prefix so both entities can coexist. +fn resolve_events( + events: Vec, + existing: &HashSet, + contract_name: &str, + merge_entities: bool, +) -> Result> { + let mut seen: HashMap = HashMap::new(); + let mut resolved = Vec::with_capacity(events.len()); + + for event in events { + // Disambiguate events overloaded within this ABI. + let count = seen.entry(event.name.clone()).or_insert(0); + let alias = if *count == 0 { + event.name.clone() + } else { + format!("{}{}", event.name, count) + }; + *count += 1; + + let (entity_name, declare_in_schema) = if existing.contains(&event.name) { + if merge_entities { + // Reuse the existing entity. NOTE: this does not verify that the + // two events share a signature; an incompatible merge surfaces at + // codegen/build. + (event.name.clone(), false) + } else { + let prefixed = format!("{}{}", contract_name, alias); + if existing.contains(&prefixed) { + return Err(anyhow!( + "Entity '{}' already exists; cannot rename '{}' to avoid a collision. Choose a different contract name.", + prefixed, + event.name + )); + } + (prefixed, true) + } + } else { + (alias.clone(), true) + }; + + resolved.push(ResolvedEvent { + event, + alias, + entity_name, + declare_in_schema, + }); + } + + Ok(resolved) +} + /// Append entity type definitions for the new events to the subgraph schema. /// /// The mapping and manifest reference one entity per event; without this the @@ -302,9 +387,23 @@ fn existing_source_names(manifest: &serde_yaml::Value) -> HashSet { fn add_schema_entities( project_dir: &Path, manifest: &serde_yaml::Value, - events: &[EventInfo], + events: &[ResolvedEvent], ) -> Result<()> { - if events.is_empty() { + let mut additions = String::new(); + for resolved in events { + // Reused entities already exist in the schema; don't redeclare them. + if !resolved.declare_in_schema { + continue; + } + additions.push('\n'); + additions.push_str(&generate_event_entity( + &resolved.entity_name, + &resolved.event.inputs, + )); + additions.push('\n'); + } + + if additions.is_empty() { return Ok(()); } @@ -315,13 +414,6 @@ fn add_schema_entities( .unwrap_or("schema.graphql"); let schema_path = project_dir.join(schema_file); - let mut additions = String::new(); - for event in events { - additions.push('\n'); - additions.push_str(&generate_event_entity(&event.name, &event.inputs)); - additions.push('\n'); - } - step(Step::Write, &format!("Updating {}", schema_path.display())); use std::io::Write; @@ -343,7 +435,7 @@ fn update_manifest( contract_name: &str, network: &str, start_block: Option, - events: &[EventInfo], + events: &[ResolvedEvent], ) -> Result<()> { let content = fs::read_to_string(manifest_path).context("Failed to read manifest")?; @@ -369,15 +461,15 @@ fn update_manifest( // Build event handlers let mut event_handlers = Vec::new(); - for event in events { + for resolved in events { let mut handler = serde_yaml::Mapping::new(); handler.insert( serde_yaml::Value::String("event".to_string()), - serde_yaml::Value::String(event.signature.clone()), + serde_yaml::Value::String(resolved.event.signature.clone()), ); handler.insert( serde_yaml::Value::String("handler".to_string()), - serde_yaml::Value::String(format!("handle{}", event.name)), + serde_yaml::Value::String(format!("handle{}", resolved.alias)), ); event_handlers.push(serde_yaml::Value::Mapping(handler)); } @@ -385,7 +477,7 @@ fn update_manifest( // Build entities list let entities: Vec = events .iter() - .map(|e| serde_yaml::Value::String(e.name.clone())) + .map(|e| serde_yaml::Value::String(e.entity_name.clone())) .collect(); // Build ABI entry @@ -513,4 +605,79 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Manifest file")); } + + fn ev(name: &str) -> EventInfo { + EventInfo { + name: name.to_string(), + signature: format!("{}()", name), + inputs: vec![], + } + } + + fn entities(names: &[&str]) -> HashSet { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_resolve_events_no_collision() { + let resolved = + resolve_events(vec![ev("Transfer")], &entities(&[]), "Token", false).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].alias, "Transfer"); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert!(resolved[0].declare_in_schema); + } + + #[test] + fn test_resolve_events_overload_disambiguates() { + // Two events named Transfer in one ABI get distinct aliases. + let resolved = resolve_events( + vec![ev("Transfer"), ev("Transfer")], + &entities(&[]), + "Token", + false, + ) + .unwrap(); + assert_eq!(resolved[0].alias, "Transfer"); + assert_eq!(resolved[1].alias, "Transfer1"); + assert_eq!(resolved[1].entity_name, "Transfer1"); + } + + #[test] + fn test_resolve_events_collision_merge_reuses() { + let resolved = resolve_events( + vec![ev("Transfer")], + &entities(&["Transfer"]), + "Token", + true, + ) + .unwrap(); + // Reuse the existing entity, don't redeclare it, but keep the handler. + assert_eq!(resolved[0].entity_name, "Transfer"); + assert!(!resolved[0].declare_in_schema); + } + + #[test] + fn test_resolve_events_collision_no_merge_renames() { + let resolved = resolve_events( + vec![ev("Transfer")], + &entities(&["Transfer"]), + "Token", + false, + ) + .unwrap(); + assert_eq!(resolved[0].entity_name, "TokenTransfer"); + assert!(resolved[0].declare_in_schema); + } + + #[test] + fn test_resolve_events_collision_renamed_also_exists_errors() { + let result = resolve_events( + vec![ev("Transfer")], + &entities(&["Transfer", "TokenTransfer"]), + "Token", + false, + ); + assert!(result.is_err()); + } } diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index eea9c21b2c8..4a4ac3ab509 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -111,11 +111,14 @@ pub struct EventInfo { /// - `entity_name` names the GraphQL entity type, the `new` expression and the /// schema import; it gains a contract prefix when it collides with an entity /// that already exists in the subgraph. +/// - `declare_in_schema` is false when the event reuses an entity that already +/// exists (a merge), so the type must not be redeclared. #[derive(Debug, Clone)] pub struct ResolvedEvent { pub event: EventInfo, pub alias: String, pub entity_name: String, + pub declare_in_schema: bool, } impl ResolvedEvent { @@ -128,6 +131,7 @@ impl ResolvedEvent { event, alias: name.clone(), entity_name: name, + declare_in_schema: true, } } } diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index ba91ddadad8..751577e3ff2 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -473,15 +473,73 @@ fn test_add_datasource() { "Updated manifest should have second contract address" ); - // The new event entity must be declared in the schema so codegen/build work. + // The added Trigger event collides with the first contract's Trigger entity; + // without --merge-entities it is renamed with the contract prefix so both + // can coexist, and declared in the schema. let schema_after = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); assert!( - schema_after.contains("type Trigger @entity"), - "Updated schema should declare the Trigger entity, got:\n{}", + schema_after.contains("type SecondContractTrigger @entity"), + "Updated schema should declare the renamed entity, got:\n{}", schema_after ); } +#[test] +fn test_add_merge_entities_reuses_existing() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("merge-test"); + + // Init with --index-events so the schema actually declares the event entities. + let abi_path = test_abis_path().join("SimpleContract.json"); + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi_path.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "FirstContract", + "--index-events", + "merge-test", + ], + temp_dir.path(), + ); + + // Add a contract whose Trigger event collides, with --merge-entities. + let second_abi_path = test_abis_path().join("LimitedContract.json"); + run_gnd_success( + &[ + "add", + "0x2222222222222222222222222222222222222222", + "--abi", + second_abi_path.to_str().unwrap(), + "--contract-name", + "SecondContract", + "--merge-entities", + ], + &subgraph_dir, + ); + + // Merge reuses the existing Trigger entity: no renamed type is declared... + let schema_after = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + !schema_after.contains("SecondContractTrigger"), + "merge should reuse Trigger, not declare a renamed entity, got:\n{}", + schema_after + ); + + // ...but the handler is still generated, writing into the shared entity. + let mapping = fs::read_to_string(subgraph_dir.join("src").join("second-contract.ts")).unwrap(); + assert!( + mapping.contains("new Trigger("), + "merged handler should write into the existing Trigger entity, got:\n{}", + mapping + ); +} + #[test] fn test_add_duplicate_name_errors() { let temp_dir = TempDir::new().unwrap(); From 2777a8acd9069b25f65834aa71ab49aa6b09757c Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 12:28:07 +0530 Subject: [PATCH 04/11] gnd: Map small integer types to GraphQL Int Solidity integers narrow enough to fit an i32 (signed up to 32 bits, unsigned up to 24 bits) now map to GraphQL Int, matching the generated AssemblyScript event params; wider integers stay BigInt. Previously every int/uint became BigInt, which mismatched the bindings. --- gnd/src/scaffold/schema.rs | 48 ++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index f645636332c..9561bf62465 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -89,6 +89,7 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { return match solidity_to_graphql(inner) { "Bytes" => "[Bytes!]", "BigInt" => "[BigInt!]", + "Int" => "[Int!]", "String" => "[String!]", "Boolean" => "[Boolean!]", _ => "[Bytes!]", @@ -113,14 +114,37 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { | "bytes23" | "bytes24" | "bytes25" | "bytes26" | "bytes27" | "bytes28" | "bytes29" | "bytes30" | "bytes31" | "bytes32" => "Bytes", - // Integer types - all map to BigInt for simplicity - t if t.starts_with("uint") || t.starts_with("int") => "BigInt", + // Integers: small widths fit in an i32 (GraphQL Int), the rest need BigInt. + t if t.starts_with("uint") || t.starts_with("int") => int_to_graphql(t), // Default to Bytes for unknown types _ => "Bytes", } } +/// Map a Solidity integer type to GraphQL `Int` when it fits in an i32, else +/// `BigInt`. An i32 holds signed ints up to 32 bits and unsigned ints up to 24 +/// bits, matching graph-cli's AssemblyScript type conversion. +fn int_to_graphql(solidity_type: &str) -> &'static str { + let (signed, width) = match solidity_type.strip_prefix("uint") { + Some(rest) => (false, rest), + None => match solidity_type.strip_prefix("int") { + Some(rest) => (true, rest), + None => return "BigInt", + }, + }; + + // A bare `int` / `uint` is 256 bits. + let bits: u32 = if width.is_empty() { + 256 + } else { + width.parse().unwrap_or(256) + }; + + let fits_i32 = if signed { bits <= 32 } else { bits <= 24 }; + if fits_i32 { "Int" } else { "BigInt" } +} + #[cfg(test)] mod tests { use super::*; @@ -230,11 +254,27 @@ mod tests { assert_eq!(solidity_to_graphql("address"), "Bytes"); assert_eq!(solidity_to_graphql("bool"), "Boolean"); assert_eq!(solidity_to_graphql("string"), "String"); - assert_eq!(solidity_to_graphql("uint256"), "BigInt"); - assert_eq!(solidity_to_graphql("int8"), "BigInt"); assert_eq!(solidity_to_graphql("bytes32"), "Bytes"); assert_eq!(solidity_to_graphql("bytes"), "Bytes"); assert_eq!(solidity_to_graphql("address[]"), "[Bytes!]"); assert_eq!(solidity_to_graphql("uint256[]"), "[BigInt!]"); } + + #[test] + fn test_integer_width_mapping() { + // Small widths that fit in an i32 map to Int. + assert_eq!(solidity_to_graphql("int8"), "Int"); + assert_eq!(solidity_to_graphql("int32"), "Int"); + assert_eq!(solidity_to_graphql("uint8"), "Int"); + assert_eq!(solidity_to_graphql("uint24"), "Int"); + // Wider integers need BigInt (uint32 does not fit an i32). + assert_eq!(solidity_to_graphql("uint32"), "BigInt"); + assert_eq!(solidity_to_graphql("int40"), "BigInt"); + assert_eq!(solidity_to_graphql("uint256"), "BigInt"); + assert_eq!(solidity_to_graphql("int"), "BigInt"); + assert_eq!(solidity_to_graphql("uint"), "BigInt"); + // Arrays follow the element type. + assert_eq!(solidity_to_graphql("int8[]"), "[Int!]"); + assert_eq!(solidity_to_graphql("uint64[]"), "[BigInt!]"); + } } From e8867e311bf18afd81631379749aad4591184498 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 13:41:30 +0530 Subject: [PATCH 05/11] gnd: Escape reserved-word event parameter names A parameter named `new`, `class`, etc. produced an entity field the generated AssemblyScript cannot declare. Suffix reserved words with `_`. gnd's existing field-name conventions (camelCase, eventId/eventType) are kept intentionally. --- gnd/src/commands/add.rs | 5 ++- gnd/src/scaffold/naming.rs | 64 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index af4a021f6fd..732e72857de 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -350,9 +350,8 @@ fn resolve_events( let (entity_name, declare_in_schema) = if existing.contains(&event.name) { if merge_entities { - // Reuse the existing entity. NOTE: this does not verify that the - // two events share a signature; an incompatible merge surfaces at - // codegen/build. + // Reuse the existing entity. Merge is by name only, so a + // signature mismatch surfaces at codegen/build, not here. (event.name.clone(), false) } else { let prefixed = format!("{}{}", contract_name, alias); diff --git a/gnd/src/scaffold/naming.rs b/gnd/src/scaffold/naming.rs index 069e14d9bec..b14fe8cdbef 100644 --- a/gnd/src/scaffold/naming.rs +++ b/gnd/src/scaffold/naming.rs @@ -36,14 +36,66 @@ pub(crate) fn sanitize_field_name(name: &str) -> String { } } - // Avoid GraphQL reserved field names. - match result.as_str() { + // Avoid clashing with the entity `id` field and the GraphQL `type` keyword. + let result = match result.as_str() { "id" => "eventId".to_string(), "type" => "eventType".to_string(), _ => result, + }; + + // Suffix reserved words so the generated AssemblyScript stays valid. + if RESERVED_WORDS.contains(&result.as_str()) { + format!("{result}_") + } else { + result } } +/// Words reserved in JS / AssemblyScript that cannot be used as identifiers as-is. +const RESERVED_WORDS: &[&str] = &[ + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "function", + "if", + "implements", + "import", + "in", + "interface", + "let", + "new", + "package", + "private", + "protected", + "public", + "return", + "super", + "switch", + "static", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "while", + "with", + "yield", +]; + #[cfg(test)] mod tests { use super::*; @@ -58,9 +110,15 @@ mod tests { // Leading uppercase -> camelCase. assert_eq!(sanitize_field_name("Owner"), "owner"); assert_eq!(sanitize_field_name("TokenId"), "tokenId"); - // Reserved words. + // Names that clash with the entity id / GraphQL keyword. assert_eq!(sanitize_field_name("id"), "eventId"); assert_eq!(sanitize_field_name("type"), "eventType"); + // Reserved words are suffixed so the generated code compiles. + assert_eq!(sanitize_field_name("new"), "new_"); + assert_eq!(sanitize_field_name("class"), "class_"); + assert_eq!(sanitize_field_name("return"), "return_"); + // Leading uppercase reserved word still resolves after camelCasing. + assert_eq!(sanitize_field_name("New"), "new_"); // Leading digit -> underscore prefix. assert_eq!(sanitize_field_name("0value"), "_0value"); // Non-alphanumeric -> underscore. From f39ab9d8334bd176d951deb02cf6671b5db14320 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 14:03:14 +0530 Subject: [PATCH 06/11] gnd: Test overloaded-event disambiguation in `add` End-to-end check that two events sharing a name in one ABI produce distinct entities (Ping, Ping1) and handlers (handlePing, handlePing1) through the add path. --- gnd/tests/cli_commands.rs | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index 751577e3ff2..5a3a686e11a 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -540,6 +540,68 @@ fn test_add_merge_entities_reuses_existing() { ); } +#[test] +fn test_add_overloaded_events_disambiguate() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("over-test"); + + // A base subgraph without overloaded events. + let abi_path = test_abis_path().join("LimitedContract.json"); + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi_path.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "Base", + "over-test", + ], + temp_dir.path(), + ); + + // An ABI with two events of the same name (a Solidity overload). + let overloaded_abi = temp_dir.path().join("Overloaded.json"); + fs::write( + &overloaded_abi, + r#"[ + {"type":"event","name":"Ping","inputs":[{"name":"account","type":"address","indexed":true}]}, + {"type":"event","name":"Ping","inputs":[{"name":"amount","type":"uint256","indexed":false}]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "add", + "0x2222222222222222222222222222222222222222", + "--abi", + overloaded_abi.to_str().unwrap(), + "--contract-name", + "Over", + ], + &subgraph_dir, + ); + + // The two Ping events get distinct entities and handlers. + let schema = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema.contains("type Ping @entity") && schema.contains("type Ping1 @entity"), + "overloaded events should produce Ping and Ping1 entities, got:\n{}", + schema + ); + + let mapping = fs::read_to_string(subgraph_dir.join("src").join("over.ts")).unwrap(); + assert!( + mapping.contains("handlePing(") && mapping.contains("handlePing1("), + "overloaded events should produce handlePing and handlePing1, got:\n{}", + mapping + ); +} + #[test] fn test_add_duplicate_name_errors() { let temp_dir = TempDir::new().unwrap(); From 06c6ecaca6785eb0ef1234cc54cb87871112f28b Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Mon, 29 Jun 2026 15:28:22 +0530 Subject: [PATCH 07/11] gnd: Disambiguate overloaded events in init scaffolding Move overloaded-event name disambiguation into a shared disambiguate_events helper used by both init's generators and add's resolve_events. init previously emitted duplicate entity types and handlers for an ABI with two same-named events; it now suffixes repeats (Ping, Ping1) the way add does. --- gnd/src/commands/add.rs | 63 ++++++++++---------------- gnd/src/scaffold/manifest.rs | 85 +++++++++++++++++++++++------------- gnd/src/scaffold/mapping.rs | 25 ++++++----- gnd/src/scaffold/mod.rs | 3 +- gnd/src/scaffold/schema.rs | 6 +-- gnd/tests/cli_commands.rs | 49 +++++++++++++++++++++ 6 files changed, 145 insertions(+), 86 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index 732e72857de..bd59d595c06 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -3,7 +3,7 @@ //! This command adds a new data source to an existing subgraph, generating //! the necessary manifest entries, schema types, and mapping stubs. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; @@ -16,8 +16,8 @@ use crate::formatter::format_typescript; use crate::output::{Step, step}; use crate::scaffold::manifest::{EventInfo, extract_events_from_abi}; use crate::scaffold::{ - MAPPING_API_VERSION, ResolvedEvent, ScaffoldOptions, generate_event_entity, - generate_event_handlers, to_kebab_case, + MAPPING_API_VERSION, ResolvedEvent, ScaffoldOptions, disambiguate_events, + generate_event_entity, generate_event_handlers, to_kebab_case, }; use crate::services::ContractService; @@ -335,45 +335,28 @@ fn resolve_events( contract_name: &str, merge_entities: bool, ) -> Result> { - let mut seen: HashMap = HashMap::new(); - let mut resolved = Vec::with_capacity(events.len()); - - for event in events { - // Disambiguate events overloaded within this ABI. - let count = seen.entry(event.name.clone()).or_insert(0); - let alias = if *count == 0 { - event.name.clone() + let mut resolved = disambiguate_events(events); + + for r in &mut resolved { + if !existing.contains(&r.event.name) { + continue; + } + if merge_entities { + // Reuse the existing entity. Merge is by name only, so a + // signature mismatch surfaces at codegen/build, not here. + r.entity_name = r.event.name.clone(); + r.declare_in_schema = false; } else { - format!("{}{}", event.name, count) - }; - *count += 1; - - let (entity_name, declare_in_schema) = if existing.contains(&event.name) { - if merge_entities { - // Reuse the existing entity. Merge is by name only, so a - // signature mismatch surfaces at codegen/build, not here. - (event.name.clone(), false) - } else { - let prefixed = format!("{}{}", contract_name, alias); - if existing.contains(&prefixed) { - return Err(anyhow!( - "Entity '{}' already exists; cannot rename '{}' to avoid a collision. Choose a different contract name.", - prefixed, - event.name - )); - } - (prefixed, true) + let prefixed = format!("{}{}", contract_name, r.alias); + if existing.contains(&prefixed) { + return Err(anyhow!( + "Entity '{}' already exists; cannot rename '{}' to avoid a collision. Choose a different contract name.", + prefixed, + r.event.name + )); } - } else { - (alias.clone(), true) - }; - - resolved.push(ResolvedEvent { - event, - alias, - entity_name, - declare_in_schema, - }); + r.entity_name = prefixed; + } } Ok(resolved) diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index 4a4ac3ab509..7343095cc6a 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -1,5 +1,7 @@ //! Manifest (subgraph.yaml) generation for scaffold. +use std::collections::HashMap; + use super::ScaffoldOptions; /// Generate the subgraph.yaml manifest content. @@ -21,8 +23,10 @@ pub fn generate_manifest(options: &ScaffoldOptions) -> String { source.push_str(&format!(" startBlock: {}\n", start_block)); } - // Get event handlers from ABI - let event_handlers = get_event_handlers(options); + // Resolve events once (disambiguating overloaded names) so the handlers and + // entities lists stay consistent. + let events = disambiguate_events(extract_events_from_abi(options)); + let event_handlers = get_event_handlers(contract_name, &events); format!( r#"specVersion: {spec_version} @@ -48,15 +52,12 @@ dataSources: "#, spec_version = super::SPEC_VERSION, api_version = super::MAPPING_API_VERSION, - entities = get_entities(options), + entities = get_entities(&events), ) } -/// Get event handlers from ABI. -fn get_event_handlers(options: &ScaffoldOptions) -> String { - let contract_name = &options.contract_name; - let events = extract_events_from_abi(options); - +/// Get event handlers for the manifest. +fn get_event_handlers(contract_name: &str, events: &[ResolvedEvent]) -> String { if events.is_empty() { // Default placeholder handler return format!( @@ -68,28 +69,24 @@ fn get_event_handlers(options: &ScaffoldOptions) -> String { let mut handlers = String::new(); for event in events { - let handler_name = format!("handle{}", event.name); handlers.push_str(&format!( - "\n - event: {}\n handler: {}", - event.signature, handler_name + "\n - event: {}\n handler: handle{}", + event.event.signature, event.alias )); } handlers } -/// Get entities list for manifest. -fn get_entities(options: &ScaffoldOptions) -> String { - let events = extract_events_from_abi(options); - +/// Get entities list for the manifest. +fn get_entities(events: &[ResolvedEvent]) -> String { if events.is_empty() { return "\n - ExampleEntity".to_string(); } - // Always use event names from ABI, regardless of index_events let mut entities = String::new(); for event in events { - entities.push_str(&format!("\n - {}", event.name)); + entities.push_str(&format!("\n - {}", event.entity_name)); } entities } @@ -121,19 +118,30 @@ pub struct ResolvedEvent { pub declare_in_schema: bool, } -impl ResolvedEvent { - /// Resolve an event with no disambiguation or collision handling: all names - /// equal the raw event name. Used where there are no existing entities to - /// collide with (a fresh scaffold). - pub fn passthrough(event: EventInfo) -> Self { - let name = event.name.clone(); - Self { - event, - alias: name.clone(), - entity_name: name, - declare_in_schema: true, - } - } +/// Resolve events for a fresh scaffold, disambiguating names that are overloaded +/// within one ABI by suffixing repeats (`Transfer`, `Transfer1`, ...). There are +/// no existing entities to collide with, so each entity is declared as-is. +pub fn disambiguate_events(events: Vec) -> Vec { + let mut seen: HashMap = HashMap::new(); + events + .into_iter() + .map(|event| { + let count = seen.entry(event.name.clone()).or_insert(0); + let alias = if *count == 0 { + event.name.clone() + } else { + format!("{}{}", event.name, count) + }; + *count += 1; + let entity_name = alias.clone(); + ResolvedEvent { + event, + alias, + entity_name, + declare_in_schema: true, + } + }) + .collect() } /// Event input parameter. @@ -360,4 +368,21 @@ mod tests { let sig = format_event_signature("Transfer", &inputs); assert_eq!(sig, "Transfer(indexed address,uint256)"); } + + #[test] + fn test_disambiguate_events() { + let ev = |name: &str| EventInfo { + name: name.to_string(), + signature: format!("{}()", name), + inputs: vec![], + }; + let resolved = disambiguate_events(vec![ev("Transfer"), ev("Transfer"), ev("Approval")]); + // Repeated names are suffixed; the first occurrence is unchanged. + assert_eq!(resolved[0].alias, "Transfer"); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert_eq!(resolved[1].alias, "Transfer1"); + assert_eq!(resolved[1].entity_name, "Transfer1"); + assert_eq!(resolved[2].alias, "Approval"); + assert!(resolved.iter().all(|r| r.declare_in_schema)); + } } diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index e8cf496feb4..f6d3a449d1c 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -17,7 +17,7 @@ pub fn generate_mapping(options: &ScaffoldOptions) -> String { return generate_placeholder_mapping(contract_name, &events, options); } - let resolved: Vec = events.into_iter().map(ResolvedEvent::passthrough).collect(); + let resolved = super::disambiguate_events(events); generate_event_handlers(contract_name, &resolved) } @@ -53,18 +53,20 @@ fn generate_placeholder_mapping( events: &[EventInfo], options: &ScaffoldOptions, ) -> String { + let resolved = super::disambiguate_events(events.to_vec()); + let mut output = String::new(); // Import graph-ts types output.push_str("import {\n BigInt,\n Bytes\n} from \"@graphprotocol/graph-ts\"\n"); - // Import contract class and all events + // Import contract class and all events (by disambiguated alias). output.push_str(&format!("import {{\n {contract_name},\n")); - for (i, event) in events.iter().enumerate() { - let suffix = if i < events.len() - 1 { ",\n" } else { "\n" }; + for (i, event) in resolved.iter().enumerate() { + let suffix = if i < resolved.len() - 1 { ",\n" } else { "\n" }; output.push_str(&format!( " {} as {}Event{}", - event.name, event.name, suffix + event.alias, event.alias, suffix )); } output.push_str(&format!( @@ -75,18 +77,17 @@ fn generate_placeholder_mapping( output.push_str("import { ExampleEntity } from \"../generated/schema\"\n"); // Generate first handler with full example code - let first_event = &events[0]; output.push_str(&generate_first_placeholder_handler( - first_event, + &resolved[0], contract_name, options, )); // Generate empty stub handlers for remaining events - for event in events.iter().skip(1) { + for event in resolved.iter().skip(1) { output.push_str(&format!( "\nexport function handle{}(event: {}Event): void {{}}\n", - event.name, event.name + event.alias, event.alias )); } @@ -95,15 +96,15 @@ fn generate_placeholder_mapping( /// Generate the first handler with full example code. fn generate_first_placeholder_handler( - event: &EventInfo, + event: &ResolvedEvent, contract_name: &str, options: &ScaffoldOptions, ) -> String { - let event_name = &event.name; + let event_name = &event.alias; // Generate field assignments for first 2 event params let mut field_assignments = String::new(); - for input in event.inputs.iter().take(2) { + for input in event.event.inputs.iter().take(2) { let field_name = sanitize_field_name(&input.name); field_assignments.push_str(&format!( " entity.{} = event.params.{}\n", diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 7b360be218d..97dc0509cc2 100644 --- a/gnd/src/scaffold/mod.rs +++ b/gnd/src/scaffold/mod.rs @@ -9,7 +9,8 @@ mod naming; mod schema; pub use manifest::{ - EventInfo, EventInput, ResolvedEvent, extract_events_from_abi, generate_manifest, + EventInfo, EventInput, ResolvedEvent, disambiguate_events, extract_events_from_abi, + 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 9561bf62465..ed47de3e041 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -18,11 +18,11 @@ pub fn generate_schema(options: &ScaffoldOptions) -> String { return generate_example_entity(&events[0].inputs); } - // Generate entity for each event + // Generate an entity for each event, disambiguating overloaded names. let mut schema = String::new(); - for event in events { - let entity = generate_event_entity(&event.name, &event.inputs); + for resolved in super::disambiguate_events(events) { + let entity = generate_event_entity(&resolved.entity_name, &resolved.event.inputs); schema.push_str(&entity); schema.push_str("\n\n"); } diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index 5a3a686e11a..192d201c3be 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -602,6 +602,55 @@ fn test_add_overloaded_events_disambiguate() { ); } +#[test] +fn test_init_overloaded_events_disambiguate() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("over-init"); + + // An ABI with two events of the same name (a Solidity overload). + let overloaded_abi = temp_dir.path().join("Overloaded.json"); + fs::write( + &overloaded_abi, + r#"[ + {"type":"event","name":"Ping","inputs":[{"name":"account","type":"address","indexed":true}]}, + {"type":"event","name":"Ping","inputs":[{"name":"amount","type":"uint256","indexed":false}]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + overloaded_abi.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "Over", + "--index-events", + "over-init", + ], + temp_dir.path(), + ); + + // init must disambiguate too, or it emits duplicate types / handlers. + let schema = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema.contains("type Ping @entity") && schema.contains("type Ping1 @entity"), + "init should disambiguate overloaded events, got:\n{}", + schema + ); + + let mapping = fs::read_to_string(subgraph_dir.join("src").join("over.ts")).unwrap(); + assert!( + mapping.contains("handlePing(") && mapping.contains("handlePing1("), + "init mapping should disambiguate overloaded handlers, got:\n{}", + mapping + ); +} + #[test] fn test_add_duplicate_name_errors() { let temp_dir = TempDir::new().unwrap(); From bd93e72aa3e641216aef45ed13658907cc111a2c Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 12:37:03 +0530 Subject: [PATCH 08/11] gnd: Match event.params accessor to the generated getter names The mapping wrote `entity. = event.params.`, but the ABI codegen escapes reserved-word getters (`new` -> `new_`) and names unnamed params `param`. So a parameter named `new`, or an unnamed parameter, produced a mapping referencing a getter that does not exist (or an empty accessor that did not compile). Mirror the binding's getter naming on the right-hand side via a shared event_param_accessors helper. --- gnd/src/scaffold/manifest.rs | 28 +++++++++++++++++++++ gnd/src/scaffold/mapping.rs | 47 ++++++++++++++++++++++++++++++++---- gnd/src/scaffold/mod.rs | 4 +-- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index 7343095cc6a..b0b731ff4be 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use super::ScaffoldOptions; +use crate::shared::handle_reserved_word; /// Generate the subgraph.yaml manifest content. pub fn generate_manifest(options: &ScaffoldOptions) -> String { @@ -144,6 +145,33 @@ pub fn disambiguate_events(events: Vec) -> Vec { .collect() } +/// The `event.params.` accessor for each input, mirroring the names the +/// 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 { + let mut seen: HashMap = HashMap::new(); + inputs + .iter() + .enumerate() + .map(|(index, input)| { + let base = if input.name.is_empty() { + format!("param{index}") + } else { + handle_reserved_word(&input.name) + }; + let count = seen.entry(base.clone()).or_insert(0); + let name = if *count == 0 { + base.clone() + } else { + format!("{base}{count}") + }; + *count += 1; + name + }) + .collect() +} + /// Event input parameter. #[derive(Debug, Clone)] pub struct EventInput { diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index f6d3a449d1c..8749c91ab2b 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -104,11 +104,12 @@ fn generate_first_placeholder_handler( // Generate field assignments for first 2 event params let mut field_assignments = String::new(); - for input in event.event.inputs.iter().take(2) { + let accessors = super::event_param_accessors(&event.event.inputs); + for (input, accessor) in event.event.inputs.iter().zip(&accessors).take(2) { let field_name = sanitize_field_name(&input.name); field_assignments.push_str(&format!( " entity.{} = event.params.{}\n", - field_name, input.name + field_name, accessor )); } @@ -200,13 +201,15 @@ fn generate_single_handler(resolved: &ResolvedEvent) -> String { let alias = &resolved.alias; let entity_name = &resolved.entity_name; - // Generate field assignments from event parameters. + // Generate field assignments from event parameters. The accessor mirrors the + // generated binding getter (escaped reserved words, param for unnamed). let mut field_assignments = String::new(); - for input in &resolved.event.inputs { + 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, input.name + field_name, accessor )); } @@ -398,4 +401,38 @@ mod tests { // transfer is nonpayable, should not be included assert!(!functions.contains("transfer")); } + + #[test] + fn test_generate_mapping_reserved_and_unnamed_params() { + let abi = json!([ + { + "type": "event", + "name": "Act", + "inputs": [ + {"name": "new", "type": "uint256", "indexed": false}, + {"name": "", "type": "address", "indexed": false} + ] + } + ]); + + let options = ScaffoldOptions { + contract_name: "RW".to_string(), + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let mapping = generate_mapping(&options); + // The RHS must match the generated getter: `new` -> `new_`, unnamed -> `param1`. + assert!( + mapping.contains("event.params.new_"), + "reserved param accessor should be escaped, got:\n{}", + mapping + ); + assert!( + mapping.contains("event.params.param1"), + "unnamed param accessor should be param, got:\n{}", + mapping + ); + } } diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 97dc0509cc2..72cb5d70a37 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, extract_events_from_abi, - generate_manifest, + EventInfo, EventInput, ResolvedEvent, disambiguate_events, event_param_accessors, + extract_events_from_abi, generate_manifest, }; pub use mapping::{generate_event_handlers, generate_mapping}; pub(crate) use naming::sanitize_field_name; From b5f9a778ba42ae29f7ca9c14bed6778460044de7 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 12:59:50 +0530 Subject: [PATCH 09/11] gnd: Detect collisions against schema-declared entity types resolve_events treated only the manifest's mapping.entities as existing, so a type hand-written in schema.graphql but not listed in any mapping was not seen as a collision: add appended a duplicate `type` and codegen failed on the redeclaration. Union the @entity types parsed from schema.graphql into the collision set, since the schema is the real source of truth for declared types. Falls back to manifest-only if the schema is missing or unparseable. --- gnd/src/commands/add.rs | 66 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index bd59d595c06..4d8403c7d4c 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::Parser; +use graphql_tools::parser::schema as gql; use serde_json::Value as JsonValue; use crate::config::networks::update_networks_file; @@ -123,14 +124,13 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { }; // Extract events from the ABI and resolve their names against the entities - // already present in the subgraph (handles overloads and collisions). + // already present in the subgraph (handles overloads and collisions). Both + // the manifest's entity lists and the types declared in schema.graphql count + // as existing, since either can be the source of a name collision. let events = extract_events_from_abi(&scaffold_options); - let resolved = resolve_events( - events, - &existing_entities(&manifest), - &contract_name, - opt.merge_entities, - )?; + let mut existing = existing_entities(&manifest); + existing.extend(schema_entity_types(project_dir, &manifest)); + let resolved = resolve_events(events, &existing, &contract_name, opt.merge_entities)?; // Add ABI file add_abi_file(project_dir, &contract_name, &abi)?; @@ -322,6 +322,43 @@ fn existing_entities(manifest: &serde_yaml::Value) -> HashSet { entities } +/// Collect the `@entity` type names declared in the subgraph's schema.graphql. +/// +/// The schema is the real source of truth for declared types, so a hand-written +/// `type Foo @entity` that no mapping lists in its `entities` still counts as a +/// collision. Returns empty on a missing or unparseable schema (falling back to +/// the manifest's entity lists). +fn schema_entity_types(project_dir: &Path, manifest: &serde_yaml::Value) -> HashSet { + let schema_file = manifest + .get("schema") + .and_then(|s| s.get("file")) + .and_then(|f| f.as_str()) + .unwrap_or("schema.graphql"); + let Ok(content) = fs::read_to_string(project_dir.join(schema_file)) else { + return HashSet::new(); + }; + entity_types_in_schema(&content) +} + +/// Parse GraphQL schema text and return the names of object types marked +/// `@entity`. Returns empty if the schema does not parse. +fn entity_types_in_schema(content: &str) -> HashSet { + let Ok(ast) = gql::parse_schema::(content) else { + return HashSet::new(); + }; + ast.definitions + .into_iter() + .filter_map(|def| match def { + gql::Definition::TypeDefinition(gql::TypeDefinition::Object(obj)) + if obj.directives.iter().any(|d| d.name == "entity") => + { + Some(obj.name) + } + _ => None, + }) + .collect() +} + /// Resolve event names against the entities already present in the subgraph. /// /// - Events overloaded within this ABI are disambiguated (`Transfer`, `Transfer1`). @@ -662,4 +699,19 @@ mod tests { ); assert!(result.is_err()); } + + #[test] + fn test_entity_types_in_schema() { + let schema = r#" + type Foo @entity { id: Bytes! } + type Bar @entity(immutable: true) { id: Bytes! } + type NotAnEntity { id: Bytes! } + "#; + let types = entity_types_in_schema(schema); + assert!(types.contains("Foo")); + assert!(types.contains("Bar")); + assert!(!types.contains("NotAnEntity")); + // A schema that doesn't parse yields no types rather than erroring. + assert!(entity_types_in_schema("this is not graphql {{{").is_empty()); + } } From d6f5de70acdd9a6acfb5a7cb6a528c1069bc1046 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 13:32:25 +0530 Subject: [PATCH 10/11] gnd: Use the shared reserved-word list for field names sanitize_field_name shipped its own RESERVED_WORDS list, a subset missing default/for/instanceof/null/void. A param named e.g. `void` was left unescaped as the entity field, while the schema/ABI codegen escaped the member to `void_` via the shared list, so the mapping referenced a member that did not exist. Escape via crate::shared::handle_reserved_word so both sides use one list. --- gnd/src/scaffold/naming.rs | 63 +++++++------------------------------- 1 file changed, 11 insertions(+), 52 deletions(-) diff --git a/gnd/src/scaffold/naming.rs b/gnd/src/scaffold/naming.rs index b14fe8cdbef..54d4563f2cb 100644 --- a/gnd/src/scaffold/naming.rs +++ b/gnd/src/scaffold/naming.rs @@ -43,59 +43,11 @@ pub(crate) fn sanitize_field_name(name: &str) -> String { _ => result, }; - // Suffix reserved words so the generated AssemblyScript stays valid. - if RESERVED_WORDS.contains(&result.as_str()) { - format!("{result}_") - } else { - result - } + // Escape reserved words via the shared list, so the entity field name + // matches the member the schema/ABI codegen generates from the same list. + crate::shared::handle_reserved_word(&result) } -/// Words reserved in JS / AssemblyScript that cannot be used as identifiers as-is. -const RESERVED_WORDS: &[&str] = &[ - "await", - "break", - "case", - "catch", - "class", - "const", - "continue", - "debugger", - "delete", - "do", - "else", - "enum", - "export", - "extends", - "false", - "finally", - "function", - "if", - "implements", - "import", - "in", - "interface", - "let", - "new", - "package", - "private", - "protected", - "public", - "return", - "super", - "switch", - "static", - "this", - "throw", - "true", - "try", - "typeof", - "var", - "while", - "with", - "yield", -]; - #[cfg(test)] mod tests { use super::*; @@ -113,10 +65,17 @@ mod tests { // Names that clash with the entity id / GraphQL keyword. assert_eq!(sanitize_field_name("id"), "eventId"); assert_eq!(sanitize_field_name("type"), "eventType"); - // Reserved words are suffixed so the generated code compiles. + // Reserved words are suffixed so the generated code compiles. These use + // the shared list, so words only in it (for/default/null/void/instanceof) + // are covered too — and stay consistent with the schema/ABI codegen. assert_eq!(sanitize_field_name("new"), "new_"); assert_eq!(sanitize_field_name("class"), "class_"); assert_eq!(sanitize_field_name("return"), "return_"); + assert_eq!(sanitize_field_name("for"), "for_"); + assert_eq!(sanitize_field_name("default"), "default_"); + assert_eq!(sanitize_field_name("null"), "null_"); + assert_eq!(sanitize_field_name("void"), "void_"); + assert_eq!(sanitize_field_name("instanceof"), "instanceof_"); // Leading uppercase reserved word still resolves after camelCasing. assert_eq!(sanitize_field_name("New"), "new_"); // Leading digit -> underscore prefix. From 8ee0e2062e977c8c7b23a6b020884a55ce245a60 Mon Sep 17 00:00:00 2001 From: incrypto32 Date: Tue, 30 Jun 2026 13:32:27 +0530 Subject: [PATCH 11/11] gnd: Detect collisions on the resolved entity name resolve_events checked the raw event name, so a disambiguated overload alias (e.g. Transfer1) that collides with an existing entity was neither renamed nor merged and silently redeclared the type. Check entity_name (the name we actually declare) instead. --- gnd/src/commands/add.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index 4d8403c7d4c..ef936884dfe 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -375,13 +375,15 @@ fn resolve_events( let mut resolved = disambiguate_events(events); for r in &mut resolved { - if !existing.contains(&r.event.name) { + // Check the name we would actually declare (the disambiguated alias), + // not the raw event name, so an overload alias like `Transfer1` that + // collides with an existing entity is still caught. + if !existing.contains(&r.entity_name) { continue; } if merge_entities { - // Reuse the existing entity. Merge is by name only, so a - // signature mismatch surfaces at codegen/build, not here. - r.entity_name = r.event.name.clone(); + // Reuse the existing entity (entity_name already equals it). Merge is + // by name only, so a signature mismatch surfaces at codegen/build. r.declare_in_schema = false; } else { let prefixed = format!("{}{}", contract_name, r.alias); @@ -389,7 +391,7 @@ fn resolve_events( return Err(anyhow!( "Entity '{}' already exists; cannot rename '{}' to avoid a collision. Choose a different contract name.", prefixed, - r.event.name + r.entity_name )); } r.entity_name = prefixed; @@ -700,6 +702,23 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_resolve_events_overload_alias_collision_renames() { + // The second overload's disambiguated alias (Transfer1) collides with an + // existing entity; the collision must be detected on the alias, not the + // raw event name. + let resolved = resolve_events( + vec![ev("Transfer"), ev("Transfer")], + &entities(&["Transfer1"]), + "Token", + false, + ) + .unwrap(); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert_eq!(resolved[1].alias, "Transfer1"); + assert_eq!(resolved[1].entity_name, "TokenTransfer1"); + } + #[test] fn test_entity_types_in_schema() { let schema = r#"