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/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/commands/codegen.rs b/gnd/src/commands/codegen.rs index deb0feb0ca5..45ca8b71ae0 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())); @@ -840,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/commands/init.rs b/gnd/src/commands/init.rs index 629297616d0..824e67f5bc9 100644 --- a/gnd/src/commands/init.rs +++ b/gnd/src/commands/init.rs @@ -16,10 +16,13 @@ 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}; 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, @@ -30,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, } @@ -51,6 +63,79 @@ 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 + 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. +/// - 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 => { + 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 => { + Err(removed_protocol_message(&protocol.to_string())) + } + } +} + #[derive(Clone, Debug, Parser, Default)] #[clap(about = "Create a new subgraph with basic scaffolding")] pub struct InitOpt { @@ -117,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); @@ -192,6 +286,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 +318,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 +350,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 +519,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 +1249,66 @@ 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!( + 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/manifest.rs b/gnd/src/manifest.rs index 0221070edfe..da1e429ebc6 100644 --- a/gnd/src/manifest.rs +++ b/gnd/src/manifest.rs @@ -172,6 +172,24 @@ 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 + // 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 +199,199 @@ 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). 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 `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()) + }) +} + +/// 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_belongs_to(kind, 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 +869,186 @@ 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(ð)); + + // 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] fn test_load_manifest_missing_source_abi_fails() { let temp_dir = TempDir::new().unwrap(); 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 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(); 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();