From df822f11e7026aca9d9d77e149f2eff0de086965 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Wed, 3 Jun 2026 08:49:52 -0700 Subject: [PATCH 1/6] gnd: Share ABI preprocessing between codegen and validation Move preprocess_abi_json (and add_default_event_param_names) out of the codegen command and into the shared abi module so that other components can normalize ABIs the same way codegen does. No behavior change for codegen; this is a prerequisite for making manifest validation parse ABIs identically to codegen. --- gnd/src/abi.rs | 56 +++++++++++++++++++++++++++++++++++++ gnd/src/commands/codegen.rs | 54 +---------------------------------- 2 files changed, 57 insertions(+), 53 deletions(-) diff --git a/gnd/src/abi.rs b/gnd/src/abi.rs index 0a5fa695cf4..4314e3352d2 100644 --- a/gnd/src/abi.rs +++ b/gnd/src/abi.rs @@ -40,6 +40,62 @@ pub fn normalize_abi_json(abi_str: &str) -> Result { )) } +/// 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) +/// +/// This is shared by codegen and validation so that both parse the ABI in +/// exactly the same way: an ABI that codegen accepts must also be accepted by +/// validation (and vice versa). +pub 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)), + ); + } + } + } +} + #[cfg(test)] mod tests { use super::*; 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())); From 19ad93bb3f527052b8a0ae99d3ca48351a519c45 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Wed, 3 Jun 2026 08:49:58 -0700 Subject: [PATCH 2/6] gnd: Surface ABI parse errors during codegen validation load_abi previously returned Option and silently dropped read/parse errors, so an event handler referencing a malformed ABI would skip signature validation entirely and codegen would still report success. Return Result, ManifestValidationError> instead: - missing file -> Ok(None) (reported by file-existence validation) - present but unparseable -> Err(InvalidFile), surfaced to the user - otherwise Ok(Some(abi)) The ABI is now preprocessed via abi::preprocess_abi_json so validation parses exactly what codegen parses, avoiding spurious mismatches. --- gnd/src/validation/mod.rs | 130 ++++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 34 deletions(-) diff --git a/gnd/src/validation/mod.rs b/gnd/src/validation/mod.rs index 8991df4fba5..08a264e1751 100644 --- a/gnd/src/validation/mod.rs +++ b/gnd/src/validation/mod.rs @@ -776,19 +776,23 @@ fn validate_handler_signatures( && let Some(abi_entry) = ds.abis.iter().find(|a| a.name == *source_abi) { let abi_path = manifest_dir.join(&abi_entry.file); - if let Some(contract) = load_abi(&abi_path) { - errors.extend(validate_event_signatures( - &ds.name, - source_abi, - &ds.event_handlers, - &contract, - )); - errors.extend(validate_function_signatures( - &ds.name, - source_abi, - &ds.call_handlers, - &contract, - )); + match load_abi(&abi_path) { + Ok(Some(contract)) => { + errors.extend(validate_event_signatures( + &ds.name, + source_abi, + &ds.event_handlers, + &contract, + )); + errors.extend(validate_function_signatures( + &ds.name, + source_abi, + &ds.call_handlers, + &contract, + )); + } + Ok(None) => {} + Err(e) => errors.push(e), } } } @@ -798,19 +802,23 @@ fn validate_handler_signatures( && let Some(abi_entry) = t.abis.iter().find(|a| a.name == *source_abi) { let abi_path = manifest_dir.join(&abi_entry.file); - if let Some(contract) = load_abi(&abi_path) { - errors.extend(validate_event_signatures( - &t.name, - source_abi, - &t.event_handlers, - &contract, - )); - errors.extend(validate_function_signatures( - &t.name, - source_abi, - &t.call_handlers, - &contract, - )); + match load_abi(&abi_path) { + Ok(Some(contract)) => { + errors.extend(validate_event_signatures( + &t.name, + source_abi, + &t.event_handlers, + &contract, + )); + errors.extend(validate_function_signatures( + &t.name, + source_abi, + &t.call_handlers, + &contract, + )); + } + Ok(None) => {} + Err(e) => errors.push(e), } } } @@ -818,15 +826,34 @@ fn validate_handler_signatures( errors } -/// Try to load an ABI file as a `JsonAbi`. +/// Load an ABI file as a `JsonAbi` for signature validation. +/// +/// Returns: +/// - `Ok(None)` if the file does not exist (this is reported separately by +/// [`validate_file_existence`], so we don't duplicate the error here). +/// - `Err(InvalidFile)` if the file exists but cannot be read or parsed. This +/// surfaces the failure instead of silently skipping signature validation. +/// - `Ok(Some(abi))` on success. /// -/// Returns `None` if the file doesn't exist or can't be parsed (those errors -/// are reported separately by file existence and ABI JSON validation). -fn load_abi(abi_path: &Path) -> Option { - let content = std::fs::read_to_string(abi_path).ok()?; - let normalized = crate::abi::normalize_abi_json(&content).ok()?; - let json_str = normalized.to_string(); - serde_json::from_str(&json_str).ok() +/// The ABI is preprocessed with [`crate::abi::preprocess_abi_json`] — the same +/// step codegen uses — so that validation parses exactly what codegen parses. +fn load_abi(abi_path: &Path) -> Result, ManifestValidationError> { + if !abi_path.exists() { + return Ok(None); + } + + let invalid = |reason: String| ManifestValidationError::InvalidFile { + path: abi_path.display().to_string(), + reason, + }; + + let content = + std::fs::read_to_string(abi_path).map_err(|e| invalid(format!("failed to read: {}", e)))?; + let processed = + crate::abi::preprocess_abi_json(&content).map_err(|e| invalid(format!("{}", e)))?; + let contract: JsonAbi = + serde_json::from_str(&processed).map_err(|e| invalid(format!("invalid ABI: {}", e)))?; + Ok(Some(contract)) } /// Build the event signature with `indexed` hints: `Name(indexed type1,type2,...)`. @@ -2117,6 +2144,41 @@ type Post @entity { assert!(errors.is_empty()); } + #[test] + fn test_validate_handler_signatures_reports_unparseable_abi() { + let temp_dir = TempDir::new().unwrap(); + + // ABI file exists but is not valid ABI JSON. Previously this was + // silently skipped; now it must surface as an InvalidFile error. + fs::create_dir_all(temp_dir.path().join("abis")).unwrap(); + fs::write( + temp_dir.path().join("abis/ERC20.json"), + r#"{"not": "an abi"}"#, + ) + .unwrap(); + + let mut ds = create_data_source("ds1", Some("mainnet"), Some(Version::new(0, 0, 6))); + ds.abis = vec![Abi { + name: "ERC20".to_string(), + file: "abis/ERC20.json".to_string(), + }]; + ds.source_abi = Some("ERC20".to_string()); + ds.event_handlers = vec![EventHandler { + event: "Transfer(address,address,uint256)".to_string(), + handler: "handleTransfer".to_string(), + receipt: false, + has_call_decls: false, + }]; + + let manifest = create_test_manifest(vec![ds], vec![]); + let errors = validate_handler_signatures(&manifest, temp_dir.path()); + assert_eq!(errors.len(), 1, "Expected one error, got: {:?}", errors); + assert!(matches!( + &errors[0], + ManifestValidationError::InvalidFile { .. } + )); + } + #[test] fn test_validate_handler_signatures_skips_non_ethereum() { let temp_dir = TempDir::new().unwrap(); From 36d4b36e3e9bd0cca133cfa36a9e99d99362a40e Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Wed, 3 Jun 2026 08:50:04 -0700 Subject: [PATCH 3/6] gnd: Append entities to schema.graphql in 'add' command 'gnd add' wrote the ABI, mapping stub, manifest entry and networks.json for the new data source but never updated schema.graphql, so the generated subgraph referenced entity types that did not exist and failed to compile. Add add_schema_entities, which appends a GraphQL entity type per event (reusing the new scaffold::generate_event_entities helper). Entity names already present in the schema are skipped with a notice, so re-running 'add' or adding a contract that shares event names never produces duplicate type definitions. --- gnd/src/commands/add.rs | 146 +++++++++++++++++++++++++++++++++++++ gnd/src/scaffold/mod.rs | 2 +- gnd/src/scaffold/schema.rs | 19 ++++- 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index 280253d5509..d8b79b75b13 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::Parser; +use graphql_tools::parser::schema as gql; use inflector::Inflector; use serde_json::Value as JsonValue; @@ -15,6 +16,7 @@ use crate::config::networks::update_networks_file; use crate::formatter::format_typescript; use crate::output::{Step, step}; use crate::scaffold::ScaffoldOptions; +use crate::scaffold::generate_event_entities; use crate::scaffold::manifest::{EventInfo, extract_events_from_abi}; use crate::services::ContractService; @@ -94,6 +96,14 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { .map(String::from) .unwrap_or_else(|| "mainnet".to_string()); + // Get the schema file path from the manifest (default ./schema.graphql) + let schema_rel = manifest + .get("schema") + .and_then(|s| s.get("file")) + .and_then(|f| f.as_str()) + .map(String::from) + .unwrap_or_else(|| "schema.graphql".to_string()); + // Fetch or load ABI let (abi, contract_name, start_block) = get_contract_info(&opt, &network).await?; @@ -120,6 +130,9 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { // Add mapping file add_mapping_file(project_dir, &contract_name, &events)?; + // Append entity types for the new contract's events to schema.graphql + add_schema_entities(project_dir, &schema_rel, &events)?; + // Update manifest update_manifest( &opt.manifest, @@ -288,6 +301,68 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo Ok(()) } +/// Append entity types for the new data source's events to `schema.graphql`. +/// +/// Entities whose type name already exists in the schema are skipped (with a +/// notice) so that re-running `add`, or adding a contract that shares event +/// names with an existing one, never produces duplicate type definitions that +/// would fail to compile. +fn add_schema_entities(project_dir: &Path, schema_rel: &str, events: &[EventInfo]) -> Result<()> { + let schema_path = project_dir.join(schema_rel); + + let existing = fs::read_to_string(&schema_path).unwrap_or_default(); + let mut existing_names = existing_type_names(&existing); + + let mut new_blocks: Vec = Vec::new(); + for (name, block) in generate_event_entities(events) { + if existing_names.contains(&name) { + step( + Step::Skip, + &format!("Entity {} already in schema, skipping", name), + ); + continue; + } + existing_names.insert(name); + new_blocks.push(block); + } + + if new_blocks.is_empty() { + return Ok(()); + } + + // Build the appended content, preserving existing schema content and + // separating type definitions with a blank line. + let mut content = existing.trim_end().to_string(); + if !content.is_empty() { + content.push_str("\n\n"); + } + content.push_str(&new_blocks.join("\n\n")); + content.push('\n'); + + step(Step::Write, &format!("Updating {}", schema_path.display())); + fs::write(&schema_path, content).context("Failed to write schema.graphql")?; + + Ok(()) +} + +/// Collect the names of all object types defined in a GraphQL schema. +/// +/// Returns an empty set if the schema is empty or cannot be parsed (a malformed +/// schema is surfaced later by codegen's schema validation, not here). +fn existing_type_names(schema: &str) -> std::collections::HashSet { + use gql::{Definition, TypeDefinition}; + + let mut names = std::collections::HashSet::new(); + if let Ok(doc) = gql::parse_schema::(schema) { + for def in &doc.definitions { + if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def { + names.insert(obj.name.clone()); + } + } + } + names +} + /// Generate mapping handlers for events. fn generate_mapping(contract_name: &str, events: &[EventInfo]) -> String { let mut imports = String::new(); @@ -592,6 +667,77 @@ mod tests { assert!(mapping.contains("../generated/schema")); } + #[test] + fn test_existing_type_names() { + let schema = r#" +type Transfer @entity(immutable: true) { id: Bytes! } +type Approval @entity { id: Bytes! } +"#; + let names = existing_type_names(schema); + assert!(names.contains("Transfer")); + assert!(names.contains("Approval")); + assert_eq!(names.len(), 2); + } + + #[test] + fn test_existing_type_names_empty_or_invalid() { + assert!(existing_type_names("").is_empty()); + assert!(existing_type_names("this is not graphql {{{").is_empty()); + } + + fn transfer_event() -> EventInfo { + 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, + }, + EventInput { + name: "value".to_string(), + solidity_type: "uint256".to_string(), + indexed: false, + }, + ], + } + } + + #[test] + fn test_add_schema_entities_appends_new_type() { + let dir = tempfile::tempdir().unwrap(); + let schema = "type Existing @entity { id: Bytes! }\n"; + fs::write(dir.path().join("schema.graphql"), schema).unwrap(); + + add_schema_entities(dir.path(), "schema.graphql", &[transfer_event()]).unwrap(); + + let updated = fs::read_to_string(dir.path().join("schema.graphql")).unwrap(); + // Existing content preserved + assert!(updated.contains("type Existing @entity")); + // New entity appended with id and standard block fields + assert!(updated.contains("type Transfer @entity(immutable: true)")); + assert!(updated.contains("id: Bytes!")); + assert!(updated.contains("from: Bytes!")); + assert!(updated.contains("value: BigInt!")); + assert!(updated.contains("blockNumber: BigInt!")); + assert!(updated.contains("transactionHash: Bytes!")); + } + + #[test] + fn test_add_schema_entities_skips_existing() { + let dir = tempfile::tempdir().unwrap(); + // Schema already declares a Transfer type + let schema = "type Transfer @entity { id: Bytes! }\n"; + fs::write(dir.path().join("schema.graphql"), schema).unwrap(); + + add_schema_entities(dir.path(), "schema.graphql", &[transfer_event()]).unwrap(); + + let updated = fs::read_to_string(dir.path().join("schema.graphql")).unwrap(); + // The colliding type is skipped: still exactly one `type Transfer` + assert_eq!(updated.matches("type Transfer").count(), 1); + } + #[test] fn test_generate_mapping_empty_events() { let events: Vec = vec![]; diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 2a9e7f18d39..b8b9ddb9f21 100644 --- a/gnd/src/scaffold/mod.rs +++ b/gnd/src/scaffold/mod.rs @@ -9,7 +9,7 @@ mod schema; pub use manifest::{EventInfo, EventInput, extract_events_from_abi, generate_manifest}; pub use mapping::generate_mapping; -pub use schema::generate_schema; +pub use schema::{generate_event_entities, generate_schema}; use std::fs; use std::path::Path; diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index b7141f66f3d..ba163914f7d 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -1,7 +1,7 @@ //! Schema (schema.graphql) generation for scaffold. use super::ScaffoldOptions; -use super::manifest::{EventInput, extract_events_from_abi}; +use super::manifest::{EventInfo, EventInput, extract_events_from_abi}; /// Generate the schema.graphql content. pub fn generate_schema(options: &ScaffoldOptions) -> String { @@ -54,6 +54,23 @@ fn generate_example_entity(inputs: &[EventInput]) -> String { ) } +/// Generate one GraphQL entity type per event. +/// +/// Returns `(entity_name, graphql_block)` pairs so callers can deduplicate by +/// name before appending to an existing schema (used by `gnd add`). Unlike +/// [`generate_schema`], this never emits the `ExampleEntity` placeholder. +pub fn generate_event_entities(events: &[EventInfo]) -> Vec<(String, String)> { + events + .iter() + .map(|event| { + ( + event.name.clone(), + generate_event_entity(&event.name, &event.inputs), + ) + }) + .collect() +} + /// Generate an entity type for an event. fn generate_event_entity(event_name: &str, inputs: &[EventInput]) -> String { let mut fields = String::new(); From 340a677b79cadaf00898b1d94f51d128d6ceed7e Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Wed, 3 Jun 2026 08:50:10 -0700 Subject: [PATCH 4/6] gnd: Validate contract addresses per protocol (accept NEAR accounts) The contract-address validator hardcoded Ethereum's 0x + 40-hex format, so 'gnd init --protocol near' rejected every valid NEAR named account (e.g. wnear.flux-dev). Add protocol-aware validate_contract_address: Ethereum keeps the hex check, NEAR uses the account-id rules (length 2..=64 and NEAR's naming regex), and other protocols are checked only for non-emptiness. Wire it into both the non-interactive init_from_contract path and the interactive contract-address prompt. --- gnd/src/commands/init.rs | 116 ++++++++++++++++++++++++++++++++++++--- gnd/src/commands/mod.rs | 2 +- gnd/src/prompt.rs | 36 ++++++------ 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/gnd/src/commands/init.rs b/gnd/src/commands/init.rs index 629297616d0..19577f789b0 100644 --- a/gnd/src/commands/init.rs +++ b/gnd/src/commands/init.rs @@ -16,6 +16,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::{Parser, ValueEnum}; use graphql_tools::parser::schema as gql; +use lazy_static::lazy_static; +use regex::Regex; use crate::commands::add::{AddOpt, run_add}; use crate::commands::codegen::{CodegenOpt, run_codegen}; @@ -51,6 +53,65 @@ impl std::fmt::Display for Protocol { } } +lazy_static! { + /// NEAR account-id naming rules. See + /// https://docs.near.org/concepts/protocol/account-id + static ref NEAR_ACCOUNT_RE: Regex = + Regex::new(r"^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$").unwrap(); +} + +/// Validate a contract address/account for the given protocol. +/// +/// Different protocols identify contracts differently, so validation must be +/// protocol-aware: Ethereum uses `0x`-prefixed hex addresses while NEAR uses +/// named accounts like `wnear.flux-dev`. Returns a human-readable error +/// message on failure. +/// +/// - `Ethereum`: `0x` followed by 40 hex characters. +/// - `Near`: account id of length 2..=64 matching NEAR's naming rules. +/// - Other protocols: only checked for non-emptiness (precise validation is +/// out of scope for now). +pub fn validate_contract_address(protocol: &Protocol, value: &str) -> Result<(), String> { + match protocol { + Protocol::Ethereum => { + if !value.starts_with("0x") || value.len() != 42 { + return Err(format!( + "Invalid contract address '{}'. Expected format: 0x followed by 40 hex characters.", + value + )); + } + if !value[2..].chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "Invalid contract address '{}'. Address must contain only hex characters.", + value + )); + } + Ok(()) + } + Protocol::Near => { + if value.len() < 2 || value.len() > 64 { + return Err(format!( + "Invalid NEAR account '{}'. Account must be between 2 and 64 characters.", + value + )); + } + if !NEAR_ACCOUNT_RE.is_match(value) { + return Err(format!( + "Invalid NEAR account '{}'. See https://docs.near.org/concepts/protocol/account-id", + value + )); + } + Ok(()) + } + Protocol::Cosmos | Protocol::Arweave | Protocol::Substreams => { + if value.trim().is_empty() { + return Err("Contract identifier cannot be empty".to_string()); + } + Ok(()) + } + } +} + #[derive(Clone, Debug, Parser, Default)] #[clap(about = "Create a new subgraph with basic scaffolding")] pub struct InitOpt { @@ -192,6 +253,7 @@ async fn run_interactive(opt: InitOpt) -> Result<()> { // Run the interactive form let form = InitForm::run_interactive( ®istry, + opt.protocol.clone().unwrap_or(Protocol::Ethereum), opt.network.clone(), opt.subgraph_name.clone(), opt.directory @@ -223,6 +285,7 @@ async fn run_interactive(opt: InitOpt) -> Result<()> { let contract_opt = InitOpt { subgraph_name: Some(form.subgraph_name), directory: Some(PathBuf::from(&form.directory)), + protocol: opt.protocol.clone(), from_contract: form.contract_address, contract_name: Some(form.contract_name), network: Some(form.network), @@ -254,13 +317,9 @@ async fn init_from_contract(opt: &InitOpt, prefetched: Option) -> .as_ref() .ok_or_else(|| anyhow!("Contract address is required"))?; - // Validate address format - if !address.starts_with("0x") || address.len() != 42 { - return Err(anyhow!( - "Invalid contract address '{}'. Expected format: 0x followed by 40 hex characters.", - address - )); - } + // Validate address format (protocol-aware: Ethereum hex vs NEAR account, etc.) + let protocol = opt.protocol.clone().unwrap_or(Protocol::Ethereum); + validate_contract_address(&protocol, address).map_err(|e| anyhow!(e))?; let network = opt.network.as_deref().unwrap_or("mainnet"); @@ -427,8 +486,9 @@ async fn init_from_contract(opt: &InitOpt, prefetched: Option) -> /// Loop to add more contracts interactively. async fn add_more_contracts_loop(directory: &Path, network: &str) -> Result<()> { while prompt_add_another_contract(network)? { - // Prompt for contract address - let address = prompt_contract_address()?; + // Prompt for contract address (the add flow fetches an ABI, so it is + // Ethereum-only) + let address = prompt_contract_address(&Protocol::Ethereum)?; // Fetch contract info to get defaults let fetched_info = fetch_contract_info_for_add(network, &address).await; @@ -1156,6 +1216,44 @@ mod tests { assert_eq!(Protocol::Substreams.to_string(), "substreams"); } + #[test] + fn test_validate_contract_address_ethereum() { + assert!( + validate_contract_address( + &Protocol::Ethereum, + "0x1234567890123456789012345678901234567890" + ) + .is_ok() + ); + // Too short / missing 0x + assert!(validate_contract_address(&Protocol::Ethereum, "0x1234").is_err()); + assert!(validate_contract_address(&Protocol::Ethereum, "wnear.flux-dev").is_err()); + // Right length but non-hex characters + assert!( + validate_contract_address( + &Protocol::Ethereum, + "0xzzzz567890123456789012345678901234567890" + ) + .is_err() + ); + } + + #[test] + fn test_validate_contract_address_near() { + // Valid NEAR named accounts (the case the user reported was rejected) + assert!(validate_contract_address(&Protocol::Near, "wnear.flux-dev").is_ok()); + assert!(validate_contract_address(&Protocol::Near, "token.near").is_ok()); + assert!(validate_contract_address(&Protocol::Near, "a.b.c.near").is_ok()); + // Uppercase is not allowed by NEAR rules + assert!(validate_contract_address(&Protocol::Near, "UPPER.case").is_err()); + // Too short + assert!(validate_contract_address(&Protocol::Near, "a").is_err()); + // Leading dot / empty segment + assert!(validate_contract_address(&Protocol::Near, ".near").is_err()); + // A NEAR account is not a hex address but must still be accepted + assert!(validate_contract_address(&Protocol::Near, "aurora").is_ok()); + } + #[test] fn test_should_run_interactive() { // Example mode should not be interactive diff --git a/gnd/src/commands/mod.rs b/gnd/src/commands/mod.rs index 5867cc8d584..1f461ae597c 100644 --- a/gnd/src/commands/mod.rs +++ b/gnd/src/commands/mod.rs @@ -19,7 +19,7 @@ pub use codegen::{CodegenOpt, run_codegen}; pub use create::{CreateOpt, run_create}; pub use deploy::{DeployOpt, run_deploy}; pub use dev::{DevOpt, run_dev}; -pub use init::{InitOpt, run_init}; +pub use init::{InitOpt, Protocol, run_init, validate_contract_address}; pub use publish::{PublishOpt, run_publish}; pub use remove::{RemoveOpt, run_remove}; pub use test::{TestOpt, run_test}; diff --git a/gnd/src/prompt.rs b/gnd/src/prompt.rs index ed2a2037f98..16916643ac9 100644 --- a/gnd/src/prompt.rs +++ b/gnd/src/prompt.rs @@ -8,9 +8,10 @@ use std::path::Path; use anyhow::Result; use console::{Term, style}; use inquire::parser::CustomTypeParser; -use inquire::validator::Validation; +use inquire::validator::{ErrorMessage, Validation}; use inquire::{Autocomplete, Confirm, CustomType, CustomUserError, Select, Text}; +use crate::commands::{Protocol, validate_contract_address}; use crate::output::{Step, step}; use crate::services::{ContractInfo, ContractService, Network, NetworksRegistry}; @@ -121,23 +122,21 @@ pub fn prompt_directory(default: Option<&str>) -> Result { Ok(prompt.prompt()?) } -/// Prompt for a contract address. -pub fn prompt_contract_address() -> Result { +/// Prompt for a contract address/account, validated for the given protocol. +pub fn prompt_contract_address(protocol: &Protocol) -> Result { + let help = match protocol { + Protocol::Near => "named account of the contract (e.g. wnear.flux-dev)", + _ => "0x... address of the contract", + }; + let protocol = protocol.clone(); Text::new("Contract address:") - .with_help_message("0x... address of the contract") - .with_validator(|input: &str| { - if !input.starts_with("0x") || input.len() != 42 { - Ok(Validation::Invalid( - "Address must start with 0x and be 42 characters".into(), - )) - } else if !input[2..].chars().all(|c| c.is_ascii_hexdigit()) { - Ok(Validation::Invalid( - "Address must contain only hex characters".into(), - )) - } else { - Ok(Validation::Valid) - } - }) + .with_help_message(help) + .with_validator( + move |input: &str| match validate_contract_address(&protocol, input) { + Ok(()) => Ok(Validation::Valid), + Err(e) => Ok(Validation::Invalid(ErrorMessage::Custom(e))), + }, + ) .prompt() .map_err(Into::into) } @@ -446,6 +445,7 @@ impl InitForm { pub async fn run_interactive( registry: &NetworksRegistry, // Pre-filled values from CLI args + protocol: Protocol, network: Option, subgraph_name: Option, directory: Option, @@ -503,7 +503,7 @@ impl InitForm { // Step 2: Contract address let contract_address = match from_contract { Some(addr) => addr, - None => prompt_contract_address()?, + None => prompt_contract_address(&protocol)?, }; // Step 3: Fetch contract info immediately after getting address From 290c4ce220d804f8c5a35aa004f7bb816a431c49 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Wed, 3 Jun 2026 08:50:27 -0700 Subject: [PATCH 5/6] gnd: Parse non-Ethereum (NEAR) manifests for codegen load_manifest parsed manifests with graph-node's Ethereum-typed UnresolvedSubgraphManifest, whose deserializer rejects 'kind: near', so 'gnd codegen' failed outright on NEAR subgraphs. NEAR (like cosmos/arweave/substreams) has no ABIs, and codegen only needs the schema path plus data-source/template names and mapping files. Rather than depend on each chain crate, detect these kinds and route to a lightweight parse_manifest_loose that extracts just those fields from the YAML. The Ethereum/subgraph typed path is unchanged. NEAR codegen now emits schema types only, matching graph-cli. --- gnd/src/commands/codegen.rs | 65 +++++++++++ gnd/src/manifest.rs | 224 ++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) diff --git a/gnd/src/commands/codegen.rs b/gnd/src/commands/codegen.rs index 6ececbfc504..45ca8b71ae0 100644 --- a/gnd/src/commands/codegen.rs +++ b/gnd/src/commands/codegen.rs @@ -788,6 +788,71 @@ dataSources: ); } + /// Test that codegen handles a NEAR manifest: it parses `kind: near`, + /// generates schema types, and produces no ABI directories (NEAR has no + /// ABIs). This guards the schema-only codegen path for non-Ethereum + /// protocols. + #[tokio::test] + async fn test_codegen_near_schema_only() { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path(); + let output_dir = project_dir.join("generated"); + + let manifest_content = r#" +specVersion: 0.0.5 +schema: + file: ./schema.graphql +dataSources: + - kind: near + name: receipts + network: near-mainnet + source: + account: wnear.flux-dev + startBlock: 100 + mapping: + apiVersion: 0.0.5 + language: wasm/assemblyscript + entities: + - ExampleEntity + receiptHandlers: + - handler: handleReceipt + file: ./src/receipts.ts +"#; + fs::write(project_dir.join("subgraph.yaml"), manifest_content).unwrap(); + + let schema_content = r#" +type ExampleEntity @entity(immutable: true) { + id: Bytes! + count: BigInt! +} +"#; + fs::write(project_dir.join("schema.graphql"), schema_content).unwrap(); + fs::create_dir_all(project_dir.join("src")).unwrap(); + fs::write(project_dir.join("src/receipts.ts"), "").unwrap(); + + let opt = CodegenOpt { + manifest: project_dir.join("subgraph.yaml"), + output_dir: output_dir.clone(), + skip_migrations: true, + watch: false, + ipfs: "https://api.thegraph.com/ipfs/api/v0".to_string(), + }; + generate_types(&opt).await.unwrap(); + + // Schema types are generated... + assert!( + output_dir.join("schema.ts").exists(), + "schema.ts should be generated for NEAR" + ); + let schema_ts = fs::read_to_string(output_dir.join("schema.ts")).unwrap(); + assert!(schema_ts.contains("export class ExampleEntity")); + // ...but no per-data-source ABI directory is created. + assert!( + !output_dir.join("receipts").exists(), + "NEAR data source must not produce an ABI directory" + ); + } + /// Test that codegen fails when referenced ABI file does not exist. /// /// Before Step 2, codegen did not validate the manifest. Now it calls diff --git a/gnd/src/manifest.rs b/gnd/src/manifest.rs index 0221070edfe..814772166aa 100644 --- a/gnd/src/manifest.rs +++ b/gnd/src/manifest.rs @@ -172,6 +172,16 @@ pub fn load_manifest(path: &Path) -> Result { let raw: serde_yaml::Mapping = serde_yaml::from_str(&manifest_str) .with_context(|| format!("Failed to parse manifest YAML: {:?}", path))?; + // Non-Ethereum protocols (e.g. NEAR) can't be parsed by graph-node's + // Ethereum-typed `UnresolvedSubgraphManifest`, and gnd does not depend on + // their chain crates. Codegen for these protocols only needs the schema + // path plus data-source/template names and mapping files (NEAR has no + // ABIs), so we extract just those fields directly from the YAML. + if manifest_needs_loose_parse(&raw) { + return parse_manifest_loose(&raw) + .with_context(|| format!("Failed to parse manifest: {:?}", path)); + } + // Use a dummy deployment hash for local CLI use let id = DeploymentHash::new("QmLocalDev").unwrap(); @@ -181,6 +191,162 @@ pub fn load_manifest(path: &Path) -> Result { Ok(convert_manifest(parsed)) } +/// Onchain data-source kinds for protocols that gnd parses loosely (their chain +/// crates are not gnd dependencies). Everything else (`ethereum`, +/// `ethereum/contract`, `subgraph`, `file/*`) is handled by the typed path. +const LOOSE_PROTOCOL_KINDS: &[&str] = &["near", "cosmos", "arweave", "substreams"]; + +/// Returns true if the manifest contains a data source or template whose kind +/// belongs to a non-Ethereum protocol that the typed parser cannot handle. +fn manifest_needs_loose_parse(raw: &serde_yaml::Mapping) -> bool { + let kinds = ["dataSources", "templates"].into_iter().flat_map(|key| { + raw.get(key) + .and_then(|v| v.as_sequence()) + .into_iter() + .flatten() + .filter_map(|ds| ds.get("kind").and_then(|k| k.as_str())) + }); + + kinds.into_iter().any(|kind| { + LOOSE_PROTOCOL_KINDS + .iter() + .any(|p| kind == *p || kind.starts_with(&format!("{}/", p))) + }) +} + +/// Loosely parse a non-Ethereum manifest, extracting only the fields gnd needs +/// for codegen. Ethereum-specific fields (`source_abi`, handlers, start/end +/// block, source address) are left empty/default. +fn parse_manifest_loose(raw: &serde_yaml::Mapping) -> Result { + let spec_version_str = raw + .get("specVersion") + .and_then(|v| v.as_str()) + .context("manifest is missing 'specVersion'")?; + let spec_version = Version::parse(spec_version_str) + .with_context(|| format!("invalid specVersion '{}'", spec_version_str))?; + + let schema = raw + .get("schema") + .and_then(|s| s.get("file")) + .and_then(|f| f.as_str()) + .map(String::from); + + let features = raw + .get("features") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|f| f.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let graft = raw.get("graft").and_then(|g| { + let base = g.get("base").and_then(|b| b.as_str())?.to_string(); + let block = g.get("block").and_then(|b| b.as_u64()).unwrap_or(0); + Some(GraftConfig { base, block }) + }); + + let data_sources = loose_sequence(raw, "dataSources") + .iter() + .map(loose_data_source) + .collect(); + + let templates = loose_sequence(raw, "templates") + .iter() + .map(loose_template) + .collect(); + + Ok(Manifest { + spec_version, + schema, + features, + graft, + data_sources, + templates, + }) +} + +/// Extract a top-level YAML sequence (e.g. `dataSources`) as a slice of values. +fn loose_sequence<'a>(raw: &'a serde_yaml::Mapping, key: &str) -> &'a [serde_yaml::Value] { + raw.get(key) + .and_then(|v| v.as_sequence()) + .map(|s| s.as_slice()) + .unwrap_or(&[]) +} + +/// Extract the `mapping.abis` entries from a data source / template YAML value. +fn loose_abis(ds: &serde_yaml::Value) -> Vec { + ds.get("mapping") + .and_then(|m| m.get("abis")) + .and_then(|a| a.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|abi| { + let name = abi.get("name").and_then(|n| n.as_str())?.to_string(); + let file = abi.get("file").and_then(|f| f.as_str())?.to_string(); + Some(Abi { name, file }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Read a string field from a data source / template YAML value. +fn loose_str(ds: &serde_yaml::Value, key: &str) -> Option { + ds.get(key).and_then(|v| v.as_str()).map(String::from) +} + +/// Read `mapping.` as a string from a data source / template YAML value. +fn loose_mapping_str(ds: &serde_yaml::Value, key: &str) -> Option { + ds.get("mapping") + .and_then(|m| m.get(key)) + .and_then(|v| v.as_str()) + .map(String::from) +} + +/// Build a gnd `DataSource` from a loosely-parsed non-Ethereum YAML value. +fn loose_data_source(ds: &serde_yaml::Value) -> DataSource { + DataSource { + name: loose_str(ds, "name").unwrap_or_default(), + kind: loose_str(ds, "kind").unwrap_or_default(), + network: loose_str(ds, "network"), + mapping_file: loose_mapping_str(ds, "file"), + api_version: loose_mapping_str(ds, "apiVersion").and_then(|v| Version::parse(&v).ok()), + abis: loose_abis(ds), + source_address: None, + source_abi: None, + start_block: ds + .get("source") + .and_then(|s| s.get("startBlock")) + .and_then(|b| b.as_u64()) + .unwrap_or(0), + end_block: ds + .get("source") + .and_then(|s| s.get("endBlock")) + .and_then(|b| b.as_u64()), + event_handlers: vec![], + call_handlers: vec![], + block_handlers: vec![], + } +} + +/// Build a gnd `Template` from a loosely-parsed non-Ethereum YAML value. +fn loose_template(t: &serde_yaml::Value) -> Template { + Template { + name: loose_str(t, "name").unwrap_or_default(), + kind: loose_str(t, "kind").unwrap_or_default(), + network: loose_str(t, "network"), + mapping_file: loose_mapping_str(t, "file"), + api_version: loose_mapping_str(t, "apiVersion").and_then(|v| Version::parse(&v).ok()), + abis: loose_abis(t), + source_abi: None, + event_handlers: vec![], + call_handlers: vec![], + block_handlers: vec![], + } +} + /// Convert a graph-node `UnresolvedSubgraphManifest` into gnd's `Manifest`. fn convert_manifest(m: GraphManifest) -> Manifest { let spec_version = m.spec_version; @@ -658,6 +824,64 @@ dataSources: assert_eq!(manifest.total_source_count(), 2); } + #[test] + fn test_load_manifest_near() { + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("subgraph.yaml"); + + // A NEAR manifest: account-based source, receiptHandlers, no abis. + // Previously this failed to parse because the typed parser is + // Ethereum-only. + let manifest_content = r#" +specVersion: 0.0.5 +schema: + file: ./schema.graphql +dataSources: + - kind: near + name: receipts + network: near-mainnet + source: + account: wnear.flux-dev + startBlock: 100 + mapping: + apiVersion: 0.0.5 + language: wasm/assemblyscript + entities: + - ExampleEntity + receiptHandlers: + - handler: handleReceipt + file: ./src/receipts.ts +"#; + + fs::write(&manifest_path, manifest_content).unwrap(); + + let manifest = load_manifest(&manifest_path).unwrap(); + assert_eq!(manifest.spec_version, Version::new(0, 0, 5)); + assert_eq!(manifest.schema, Some("./schema.graphql".to_string())); + assert_eq!(manifest.data_sources.len(), 1); + let ds = &manifest.data_sources[0]; + assert_eq!(ds.name, "receipts"); + assert_eq!(ds.kind, "near"); + assert_eq!(ds.network, Some("near-mainnet".to_string())); + assert_eq!(ds.mapping_file, Some("./src/receipts.ts".to_string())); + assert_eq!(ds.start_block, 100); + // NEAR has no ABIs and no Ethereum-style source.abi + assert!(ds.abis.is_empty()); + assert!(ds.source_abi.is_none()); + } + + #[test] + fn test_manifest_needs_loose_parse() { + let near: serde_yaml::Mapping = + serde_yaml::from_str("dataSources:\n - kind: near\n name: r\n").unwrap(); + assert!(manifest_needs_loose_parse(&near)); + + let eth: serde_yaml::Mapping = + serde_yaml::from_str("dataSources:\n - kind: ethereum/contract\n name: t\n") + .unwrap(); + assert!(!manifest_needs_loose_parse(ð)); + } + #[test] fn test_load_manifest_missing_source_abi_fails() { let temp_dir = TempDir::new().unwrap(); From 676f6af749b9479496b2d4fc6eec3c1889547970 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Thu, 25 Jun 2026 18:29:29 -0700 Subject: [PATCH 6/6] gnd: Reject cosmos, arweave, and substreams subgraphs graph-node no longer supports the cosmos, arweave, or substreams chains, so gnd should not let users create or process subgraphs for them. Refuse them both when creating a subgraph (`init --protocol`) and when loading an existing manifest (codegen/build/validate/test/deploy), pointing users to an older graph-cli version instead. The `--protocol` flag still accepts these values (the variants are kept but hidden from `--help` and completion) so we can emit a helpful message rather than a generic clap error. NEAR stays supported, and the still-supported `file/arweave` offchain data source is left untouched. --- gnd/src/commands/init.rs | 67 ++++++++++++-- gnd/src/manifest.rs | 187 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 238 insertions(+), 16 deletions(-) diff --git a/gnd/src/commands/init.rs b/gnd/src/commands/init.rs index 19577f789b0..824e67f5bc9 100644 --- a/gnd/src/commands/init.rs +++ b/gnd/src/commands/init.rs @@ -22,6 +22,7 @@ use regex::Regex; use crate::commands::add::{AddOpt, run_add}; use crate::commands::codegen::{CodegenOpt, run_codegen}; use crate::config::networks::update_networks_file; +use crate::manifest::removed_protocol_message; use crate::output::{Step, step, with_spinner}; use crate::prompt::{ InitForm, SourceType, get_subgraph_basename, prompt_add_another_contract, @@ -32,12 +33,21 @@ use crate::scaffold::{ScaffoldOptions, generate_scaffold, init_git, install_depe use crate::services::{ContractInfo, ContractService, IpfsClient, NetworksRegistry}; /// Available protocols for subgraph development. +/// +/// The removed protocols (`cosmos`, `arweave`, `substreams`) are kept as +/// variants so the `--protocol` flag still parses them and gnd can answer with +/// a helpful message (see [`Protocol::is_removed`] and the guard in +/// [`run_init`]). They are marked `hide` so they do not appear as valid values +/// in `--help` or shell completion. #[derive(Clone, Debug, ValueEnum)] pub enum Protocol { Ethereum, Near, + #[value(hide = true)] Cosmos, + #[value(hide = true)] Arweave, + #[value(hide = true)] Substreams, } @@ -53,6 +63,22 @@ impl std::fmt::Display for Protocol { } } +impl Protocol { + /// Whether graph-node has dropped support for this protocol's chain. + /// + /// The variants are kept so `--protocol` still accepts these values and + /// gnd can answer with a helpful message (see [`removed_protocol_message`]) + /// rather than a generic "invalid value" error. Note this concerns the + /// `arweave` *chain*, not the still-supported `file/arweave` offchain data + /// source. + pub fn is_removed(&self) -> bool { + matches!( + self, + Protocol::Cosmos | Protocol::Arweave | Protocol::Substreams + ) + } +} + lazy_static! { /// NEAR account-id naming rules. See /// https://docs.near.org/concepts/protocol/account-id @@ -69,8 +95,9 @@ lazy_static! { /// /// - `Ethereum`: `0x` followed by 40 hex characters. /// - `Near`: account id of length 2..=64 matching NEAR's naming rules. -/// - Other protocols: only checked for non-emptiness (precise validation is -/// out of scope for now). +/// - Removed protocols (`Cosmos`, `Arweave`, `Substreams`): rejected with a +/// message pointing users to an older graph-cli, since graph-node no longer +/// supports them. pub fn validate_contract_address(protocol: &Protocol, value: &str) -> Result<(), String> { match protocol { Protocol::Ethereum => { @@ -104,10 +131,7 @@ pub fn validate_contract_address(protocol: &Protocol, value: &str) -> Result<(), Ok(()) } Protocol::Cosmos | Protocol::Arweave | Protocol::Substreams => { - if value.trim().is_empty() { - return Err("Contract identifier cannot be empty".to_string()); - } - Ok(()) + Err(removed_protocol_message(&protocol.to_string())) } } } @@ -178,6 +202,15 @@ pub struct InitOpt { /// Run the init command. pub async fn run_init(opt: InitOpt) -> Result<()> { + // Refuse protocols graph-node no longer supports, regardless of source + // mode. Protocol defaults to Ethereum, so this only trips on an explicit + // `--protocol cosmos|arweave|substreams`. + if let Some(protocol) = &opt.protocol + && protocol.is_removed() + { + return Err(anyhow!(removed_protocol_message(&protocol.to_string()))); + } + // Check if we need interactive mode let needs_interactive = should_run_interactive(&opt); @@ -1216,6 +1249,28 @@ mod tests { assert_eq!(Protocol::Substreams.to_string(), "substreams"); } + #[test] + fn test_protocol_is_removed() { + assert!(!Protocol::Ethereum.is_removed()); + assert!(!Protocol::Near.is_removed()); + assert!(Protocol::Cosmos.is_removed()); + assert!(Protocol::Arweave.is_removed()); + assert!(Protocol::Substreams.is_removed()); + } + + #[test] + fn test_validate_contract_address_removed_protocols() { + for protocol in [Protocol::Cosmos, Protocol::Arweave, Protocol::Substreams] { + let err = validate_contract_address(&protocol, "anything").unwrap_err(); + assert!( + err.contains("no longer supports") && err.contains(&protocol.to_string()), + "Expected removed-protocol message for {}, got: {}", + protocol, + err + ); + } + } + #[test] fn test_validate_contract_address_ethereum() { assert!( diff --git a/gnd/src/manifest.rs b/gnd/src/manifest.rs index 814772166aa..da1e429ebc6 100644 --- a/gnd/src/manifest.rs +++ b/gnd/src/manifest.rs @@ -172,6 +172,14 @@ pub fn load_manifest(path: &Path) -> Result { let raw: serde_yaml::Mapping = serde_yaml::from_str(&manifest_str) .with_context(|| format!("Failed to parse manifest YAML: {:?}", path))?; + // graph-node no longer supports cosmos, arweave, or substreams chains, so + // reject such manifests up front with guidance to use an older graph-cli. + // (This does not affect the still-supported `file/arweave` offchain data + // source, which is matched neither by `arweave` nor `arweave/`.) + if let Some(name) = manifest_removed_protocol(&raw) { + return Err(anyhow::anyhow!(removed_protocol_message(&name))); + } + // Non-Ethereum protocols (e.g. NEAR) can't be parsed by graph-node's // Ethereum-typed `UnresolvedSubgraphManifest`, and gnd does not depend on // their chain crates. Codegen for these protocols only needs the schema @@ -192,25 +200,62 @@ pub fn load_manifest(path: &Path) -> Result { } /// Onchain data-source kinds for protocols that gnd parses loosely (their chain -/// crates are not gnd dependencies). Everything else (`ethereum`, -/// `ethereum/contract`, `subgraph`, `file/*`) is handled by the typed path. -const LOOSE_PROTOCOL_KINDS: &[&str] = &["near", "cosmos", "arweave", "substreams"]; +/// crates are not gnd dependencies). NEAR is the only non-Ethereum protocol gnd +/// still supports this way; everything else (`ethereum`, `ethereum/contract`, +/// `subgraph`, `file/*`) is handled by the typed path. +const LOOSE_PROTOCOL_KINDS: &[&str] = &["near"]; + +/// Chain kinds graph-node no longer supports. gnd refuses to create or load +/// subgraphs for these. Note this is the *chain* `arweave`, not the still- +/// supported `file/arweave` offchain file data source: the matcher below keys +/// on `kind == p || kind.starts_with("{p}/")`, and `file/arweave` neither +/// equals `arweave` nor starts with `arweave/`, so it is never matched here. +pub const REMOVED_PROTOCOL_KINDS: &[&str] = &["cosmos", "arweave", "substreams"]; + +/// Build the user-facing error explaining that a protocol has been dropped. +pub fn removed_protocol_message(name: &str) -> String { + format!( + "graph-node no longer supports {name} subgraphs. To work with {name} \ + subgraphs, install and use an older version of graph-cli \ + (https://github.com/graphprotocol/graph-tooling)." + ) +} -/// Returns true if the manifest contains a data source or template whose kind -/// belongs to a non-Ethereum protocol that the typed parser cannot handle. -fn manifest_needs_loose_parse(raw: &serde_yaml::Mapping) -> bool { - let kinds = ["dataSources", "templates"].into_iter().flat_map(|key| { +/// Returns true if `kind` belongs to `protocol` (either exactly `protocol` or a +/// sub-kind like `protocol/handler`). +fn kind_belongs_to(kind: &str, protocol: &str) -> bool { + kind == protocol || kind.starts_with(&format!("{}/", protocol)) +} + +/// Iterate over the `kind` of every data source and template in a manifest. +fn manifest_kinds(raw: &serde_yaml::Mapping) -> impl Iterator { + ["dataSources", "templates"].into_iter().flat_map(|key| { raw.get(key) .and_then(|v| v.as_sequence()) .into_iter() .flatten() .filter_map(|ds| ds.get("kind").and_then(|k| k.as_str())) - }); + }) +} + +/// Returns the base name (e.g. `cosmos`) of the first removed-protocol data +/// source or template in the manifest, if any. +fn manifest_removed_protocol(raw: &serde_yaml::Mapping) -> Option { + manifest_kinds(raw).find_map(|kind| { + REMOVED_PROTOCOL_KINDS + .iter() + .find(|p| kind_belongs_to(kind, p)) + .map(|p| p.to_string()) + }) +} - kinds.into_iter().any(|kind| { +/// Returns true if the manifest contains a data source or template whose kind +/// belongs to a non-Ethereum protocol that the typed parser cannot handle. +fn manifest_needs_loose_parse(raw: &serde_yaml::Mapping) -> bool { + manifest_kinds(raw).any(|kind| { LOOSE_PROTOCOL_KINDS .iter() - .any(|p| kind == *p || kind.starts_with(&format!("{}/", p))) + .any(|p| kind_belongs_to(kind, p)) }) } @@ -880,6 +925,128 @@ dataSources: serde_yaml::from_str("dataSources:\n - kind: ethereum/contract\n name: t\n") .unwrap(); assert!(!manifest_needs_loose_parse(ð)); + + // Removed protocols are no longer loose-parsed (they are rejected). + let cosmos: serde_yaml::Mapping = + serde_yaml::from_str("dataSources:\n - kind: cosmos\n name: c\n").unwrap(); + assert!(!manifest_needs_loose_parse(&cosmos)); + } + + #[test] + fn test_manifest_removed_protocol() { + for kind in ["cosmos", "arweave", "substreams"] { + let raw: serde_yaml::Mapping = + serde_yaml::from_str(&format!("dataSources:\n - kind: {}\n name: d\n", kind)) + .unwrap(); + assert_eq!(manifest_removed_protocol(&raw).as_deref(), Some(kind)); + } + + // Sub-kinds (e.g. `cosmos/events`) are matched and mapped to the base. + let sub: serde_yaml::Mapping = + serde_yaml::from_str("templates:\n - kind: cosmos/events\n name: d\n").unwrap(); + assert_eq!(manifest_removed_protocol(&sub).as_deref(), Some("cosmos")); + + // Supported kinds are not flagged. + for kind in ["ethereum/contract", "near", "subgraph", "file/ipfs"] { + let raw: serde_yaml::Mapping = + serde_yaml::from_str(&format!("dataSources:\n - kind: {}\n name: d\n", kind)) + .unwrap(); + assert_eq!(manifest_removed_protocol(&raw), None, "kind: {}", kind); + } + + // The still-supported `file/arweave` offchain data source must NOT be + // treated as the removed `arweave` chain. + let file_arweave: serde_yaml::Mapping = + serde_yaml::from_str("dataSources:\n - kind: file/arweave\n name: d\n").unwrap(); + assert_eq!(manifest_removed_protocol(&file_arweave), None); + } + + #[test] + fn test_load_manifest_removed_protocol_rejected() { + for kind in ["cosmos", "arweave", "substreams"] { + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("subgraph.yaml"); + + let manifest_content = format!( + r#" +specVersion: 0.0.5 +schema: + file: ./schema.graphql +dataSources: + - kind: {} + name: source + network: somenet + source: + startBlock: 0 + mapping: + apiVersion: 0.0.5 + language: wasm/assemblyscript + entities: + - ExampleEntity + file: ./src/mapping.ts +"#, + kind + ); + + fs::write(&manifest_path, manifest_content).unwrap(); + + let result = load_manifest(&manifest_path); + assert!(result.is_err(), "{} manifest should be rejected", kind); + let err = format!("{:#}", result.unwrap_err()); + assert!( + err.contains("no longer supports") && err.contains(kind), + "Error should mention the dropped protocol, got: {}", + err + ); + } + } + + #[test] + fn test_load_manifest_file_arweave_not_rejected() { + // A `file/arweave` offchain data source is still supported and must + // parse via the typed path, not be rejected as the removed chain. + let temp_dir = TempDir::new().unwrap(); + let manifest_path = temp_dir.path().join("subgraph.yaml"); + + let manifest_content = r#" +specVersion: 1.0.0 +schema: + file: ./schema.graphql +dataSources: + - kind: ethereum/contract + name: Token + network: mainnet + source: + abi: ERC20 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + file: ./src/mapping.ts + entities: + - MyEntity + abis: + - name: ERC20 + file: ./abis/ERC20.json +templates: + - kind: file/arweave + name: ArweaveContent + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + file: ./src/mapping.ts + entities: + - MyEntity + abis: [] + handler: handleArweaveContent +"#; + + fs::write(&manifest_path, manifest_content).unwrap(); + + let manifest = load_manifest(&manifest_path).unwrap(); + assert_eq!(manifest.templates.len(), 1); + assert_eq!(manifest.templates[0].kind, "file/arweave"); } #[test]