diff --git a/gnd/src/abi.rs b/gnd/src/abi.rs index 3e296a99f2d..08948367dd6 100644 --- a/gnd/src/abi.rs +++ b/gnd/src/abi.rs @@ -9,6 +9,43 @@ use anyhow::{Context, Result, anyhow}; use graph::abi::{DynSolType, Event, EventParam}; use serde_json::Value; +/// The AssemblyScript width class a Solidity integer maps to. Drives both the +/// GraphQL scalar the scaffold emits and the getter/setter the codegen emits, so +/// the schema and the generated bindings always agree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntWidth { + /// Fits an `i32` -> GraphQL `Int`. + I32, + /// Fits an `i64` -> GraphQL `Int8`. + I64, + /// Needs `BigInt`. + Big, +} + +/// Classify a Solidity integer by signedness and bit width. The cutoffs are the +/// point where a value stops fitting the target AssemblyScript int: +/// +/// - `i32` is signed 32-bit (`-2^31 ..= 2^31-1`): `int32` fits exactly, and an +/// unsigned value fits while `2^bits - 1 <= 2^31 - 1`, i.e. up to 31 bits. +/// - `i64` is signed 64-bit (`-2^63 ..= 2^63-1`): `int64` fits exactly, and an +/// unsigned value fits up to 63 bits (`uint64`'s `2^64-1` overflows i64). +/// +/// The signed cutoffs (32, 64) are exact: lowering either would push `int32` / +/// `int64` to `BigInt` even though they fit. The unsigned cutoffs (31, 63) are +/// the bit limits; Solidity int widths only come in 8-bit steps, so no real type +/// ever lands between the largest fitting width (`uint24` / `uint56`) and the +/// cutoff, making 31/63 and 24/56 equivalent for every valid ABI. +pub fn classify_int_width(signed: bool, bits: u32) -> IntWidth { + let (i32_max, i64_max) = if signed { (32, 64) } else { (31, 63) }; + if bits <= i32_max { + IntWidth::I32 + } else if bits <= i64_max { + IntWidth::I64 + } else { + IntWidth::Big + } +} + /// Resolve an `EventParam`'s declared type to `DynSolType`. pub fn resolve_event_param_type(param: &EventParam) -> DynSolType { param diff --git a/gnd/src/codegen/abi.rs b/gnd/src/codegen/abi.rs index 2ae554599ea..52c4c28ea2a 100644 --- a/gnd/src/codegen/abi.rs +++ b/gnd/src/codegen/abi.rs @@ -13,7 +13,7 @@ use graph::abi::{ use regex::Regex; use super::typescript::{self as ts, Class, ClassMember, Method, ModuleImports, Param as TsParam}; -use crate::abi::{indexed_input_type, resolve_event_param_type}; +use crate::abi::{IntWidth, classify_int_width, indexed_input_type, resolve_event_param_type}; use crate::shared::{capitalize, handle_reserved_word}; /// Resolve a `Param`'s type to `DynSolType`. @@ -941,22 +941,13 @@ impl AbiCodeGenerator { /// Disambiguate events with duplicate names. fn disambiguate_events(&self) -> Vec<(&Event, String)> { - let mut result = Vec::new(); - let mut collision_counter: HashMap = HashMap::new(); - - for event in self.contract.events() { - let name = handle_reserved_word(&event.name); - let counter = collision_counter.entry(name.clone()).or_insert(0); - let alias = if *counter == 0 { - name.clone() - } else { - format!("{}{}", name, counter) - }; - *counter += 1; - result.push((event, alias)); - } - - result + let events: Vec<&Event> = self.contract.events().collect(); + let names: Vec = events + .iter() + .map(|event| handle_reserved_word(&event.name)) + .collect(); + let aliases = crate::shared::disambiguate_names(&names); + events.into_iter().zip(aliases).collect() } /// Disambiguate functions. @@ -1121,54 +1112,64 @@ fn asc_type_for_ethereum(param_type: &DynSolType) -> String { DynSolType::Bool => "boolean".to_string(), DynSolType::Bytes => "Bytes".to_string(), DynSolType::FixedBytes(_) => "Bytes".to_string(), - DynSolType::Int(bits) => { - if *bits <= 32 { - "i32".to_string() - } else { - "BigInt".to_string() - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - "i32".to_string() - } else { - "BigInt".to_string() - } - } + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => "i32".to_string(), + IntWidth::I64 => "i64".to_string(), + IntWidth::Big => "BigInt".to_string(), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => "i32".to_string(), + IntWidth::I64 => "i64".to_string(), + IntWidth::Big => "BigInt".to_string(), + }, DynSolType::String => "string".to_string(), - DynSolType::Array(inner) => { - let inner_type = asc_type_for_ethereum(inner); - format!("Array<{}>", inner_type) - } - DynSolType::FixedArray(inner, _) => { - let inner_type = asc_type_for_ethereum(inner); - format!("Array<{}>", inner_type) + DynSolType::Array(inner) | DynSolType::FixedArray(inner, _) => { + format!("Array<{}>", asc_array_element_type(inner)) } DynSolType::Tuple(_) => "ethereum.Tuple".to_string(), _ => "ethereum.Tuple".to_string(), // Function and other future variants } } +/// The AssemblyScript element type inside an array or matrix. The `Int8` (i64) +/// band collapses to `BigInt` here — `ethereum.Value` has no i64 array accessor, +/// so array/matrix getters decode via `toBigIntArray()`/`toBigIntMatrix()`. This +/// keeps the declared type in step with those conversion sites and the +/// `[BigInt!]` schema type; only the i32 band keeps a narrow element. +fn asc_array_element_type(inner: &DynSolType) -> String { + match inner { + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => "i32".to_string(), + _ => "BigInt".to_string(), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => "i32".to_string(), + _ => "BigInt".to_string(), + }, + DynSolType::Array(next) | DynSolType::FixedArray(next, _) => { + format!("Array<{}>", asc_array_element_type(next)) + } + _ => asc_type_for_ethereum(inner), + } +} + /// Convert ethereum value to AssemblyScript. fn ethereum_to_asc(code: &str, param_type: &DynSolType, tuple_type: Option<&str>) -> String { match param_type { DynSolType::Address => format!("{}.toAddress()", code), DynSolType::Bool => format!("{}.toBoolean()", code), DynSolType::Bytes | DynSolType::FixedBytes(_) => format!("{}.toBytes()", code), - DynSolType::Int(bits) => { - if *bits <= 32 { - format!("{}.toI32()", code) - } else { - format!("{}.toBigInt()", code) - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - format!("{}.toI32()", code) - } else { - format!("{}.toBigInt()", code) - } - } + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => format!("{}.toI32()", code), + // `ethereum.Value` has no i64 accessor, so reach i64 via BigInt. + IntWidth::I64 => format!("{}.toBigInt().toI64()", code), + IntWidth::Big => format!("{}.toBigInt()", code), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => format!("{}.toI32()", code), + IntWidth::I64 => format!("{}.toBigInt().toI64()", code), + IntWidth::Big => format!("{}.toBigInt()", code), + }, DynSolType::String => format!("{}.toString()", code), DynSolType::Array(inner) | DynSolType::FixedArray(inner, _) => match inner.as_ref() { DynSolType::Address => format!("{}.toAddressArray()", code), @@ -1176,20 +1177,16 @@ fn ethereum_to_asc(code: &str, param_type: &DynSolType, tuple_type: Option<&str> DynSolType::Bytes | DynSolType::FixedBytes(_) => { format!("{}.toBytesArray()", code) } - DynSolType::Int(bits) => { - if *bits <= 32 { - format!("{}.toI32Array()", code) - } else { - format!("{}.toBigIntArray()", code) - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - format!("{}.toI32Array()", code) - } else { - format!("{}.toBigIntArray()", code) - } - } + // No i64 array accessor on `ethereum.Value`, so mid-size int arrays + // stay BigInt, matching the `[BigInt!]` schema type. + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => format!("{}.toI32Array()", code), + _ => format!("{}.toBigIntArray()", code), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => format!("{}.toI32Array()", code), + _ => format!("{}.toBigIntArray()", code), + }, DynSolType::String => format!("{}.toStringArray()", code), DynSolType::Tuple(_) => { if let Some(tuple_name) = tuple_type { @@ -1214,20 +1211,14 @@ fn ethereum_to_asc_matrix(code: &str, inner_type: &DynSolType, tuple_type: Optio DynSolType::Address => format!("{}.toAddressMatrix()", code), DynSolType::Bool => format!("{}.toBooleanMatrix()", code), DynSolType::Bytes | DynSolType::FixedBytes(_) => format!("{}.toBytesMatrix()", code), - DynSolType::Int(bits) => { - if *bits <= 32 { - format!("{}.toI32Matrix()", code) - } else { - format!("{}.toBigIntMatrix()", code) - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - format!("{}.toI32Matrix()", code) - } else { - format!("{}.toBigIntMatrix()", code) - } - } + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => format!("{}.toI32Matrix()", code), + _ => format!("{}.toBigIntMatrix()", code), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => format!("{}.toI32Matrix()", code), + _ => format!("{}.toBigIntMatrix()", code), + }, DynSolType::String => format!("{}.toStringMatrix()", code), DynSolType::Tuple(_) => { if let Some(tuple_name) = tuple_type { @@ -1247,23 +1238,22 @@ fn ethereum_from_asc(code: &str, param_type: &DynSolType) -> String { DynSolType::Bool => format!("ethereum.Value.fromBoolean({})", code), DynSolType::Bytes => format!("ethereum.Value.fromBytes({})", code), DynSolType::FixedBytes(_) => format!("ethereum.Value.fromFixedBytes({})", code), - DynSolType::Int(bits) => { - if *bits <= 32 { - format!("ethereum.Value.fromI32({})", code) - } else { - format!("ethereum.Value.fromSignedBigInt({})", code) - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - format!( - "ethereum.Value.fromUnsignedBigInt(BigInt.fromI32({}))", - code - ) - } else { - format!("ethereum.Value.fromUnsignedBigInt({})", code) - } - } + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => format!("ethereum.Value.fromI32({})", code), + IntWidth::I64 => format!("ethereum.Value.fromSignedBigInt(BigInt.fromI64({}))", code), + IntWidth::Big => format!("ethereum.Value.fromSignedBigInt({})", code), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => format!( + "ethereum.Value.fromUnsignedBigInt(BigInt.fromI32({}))", + code + ), + IntWidth::I64 => format!( + "ethereum.Value.fromUnsignedBigInt(BigInt.fromI64({}))", + code + ), + IntWidth::Big => format!("ethereum.Value.fromUnsignedBigInt({})", code), + }, DynSolType::String => format!("ethereum.Value.fromString({})", code), DynSolType::Array(inner) | DynSolType::FixedArray(inner, _) => { ethereum_from_asc_array(code, inner.as_ref()) @@ -1280,20 +1270,14 @@ fn ethereum_from_asc_array(code: &str, inner_type: &DynSolType) -> String { DynSolType::Bool => format!("ethereum.Value.fromBooleanArray({})", code), DynSolType::Bytes => format!("ethereum.Value.fromBytesArray({})", code), DynSolType::FixedBytes(_) => format!("ethereum.Value.fromFixedBytesArray({})", code), - DynSolType::Int(bits) => { - if *bits <= 32 { - format!("ethereum.Value.fromI32Array({})", code) - } else { - format!("ethereum.Value.fromSignedBigIntArray({})", code) - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - format!("ethereum.Value.fromI32Array({})", code) - } else { - format!("ethereum.Value.fromUnsignedBigIntArray({})", code) - } - } + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => format!("ethereum.Value.fromI32Array({})", code), + _ => format!("ethereum.Value.fromSignedBigIntArray({})", code), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => format!("ethereum.Value.fromI32Array({})", code), + _ => format!("ethereum.Value.fromUnsignedBigIntArray({})", code), + }, DynSolType::String => format!("ethereum.Value.fromStringArray({})", code), DynSolType::Tuple(_) => format!("ethereum.Value.fromTupleArray({})", code), DynSolType::Array(inner2) | DynSolType::FixedArray(inner2, _) => { @@ -1310,20 +1294,14 @@ fn ethereum_from_asc_matrix(code: &str, inner_type: &DynSolType) -> String { DynSolType::Bool => format!("ethereum.Value.fromBooleanMatrix({})", code), DynSolType::Bytes => format!("ethereum.Value.fromBytesMatrix({})", code), DynSolType::FixedBytes(_) => format!("ethereum.Value.fromFixedBytesMatrix({})", code), - DynSolType::Int(bits) => { - if *bits <= 32 { - format!("ethereum.Value.fromI32Matrix({})", code) - } else { - format!("ethereum.Value.fromSignedBigIntMatrix({})", code) - } - } - DynSolType::Uint(bits) => { - if *bits <= 24 { - format!("ethereum.Value.fromI32Matrix({})", code) - } else { - format!("ethereum.Value.fromUnsignedBigIntMatrix({})", code) - } - } + DynSolType::Int(bits) => match classify_int_width(true, *bits as u32) { + IntWidth::I32 => format!("ethereum.Value.fromI32Matrix({})", code), + _ => format!("ethereum.Value.fromSignedBigIntMatrix({})", code), + }, + DynSolType::Uint(bits) => match classify_int_width(false, *bits as u32) { + IntWidth::I32 => format!("ethereum.Value.fromI32Matrix({})", code), + _ => format!("ethereum.Value.fromUnsignedBigIntMatrix({})", code), + }, DynSolType::String => format!("ethereum.Value.fromStringMatrix({})", code), DynSolType::Tuple(_) => format!("ethereum.Value.fromTupleMatrix({})", code), _ => format!("ethereum.Value.fromStringMatrix({})", code), // fallback @@ -1423,10 +1401,64 @@ mod tests { assert_eq!(asc_type_for_ethereum(&DynSolType::Uint(256)), "BigInt"); assert_eq!(asc_type_for_ethereum(&DynSolType::Uint(8)), "i32"); assert_eq!(asc_type_for_ethereum(&DynSolType::Int(32)), "i32"); + // Mid-size ints fit i64. + assert_eq!(asc_type_for_ethereum(&DynSolType::Int(40)), "i64"); + assert_eq!(asc_type_for_ethereum(&DynSolType::Int(64)), "i64"); + assert_eq!(asc_type_for_ethereum(&DynSolType::Uint(32)), "i64"); + assert_eq!(asc_type_for_ethereum(&DynSolType::Uint(56)), "i64"); + // Too wide for i64. + assert_eq!(asc_type_for_ethereum(&DynSolType::Int(72)), "BigInt"); + assert_eq!(asc_type_for_ethereum(&DynSolType::Uint(64)), "BigInt"); assert_eq!(asc_type_for_ethereum(&DynSolType::String), "string"); assert_eq!(asc_type_for_ethereum(&DynSolType::Bytes), "Bytes"); } + #[test] + fn test_int8_band_getters_and_setters() { + // `ethereum.Value` has no i64 accessor, so the scalar getter reaches i64 + // via BigInt, and the setter wraps an i64 back through BigInt. + assert_eq!( + ethereum_to_asc("x", &DynSolType::Int(40), None), + "x.toBigInt().toI64()" + ); + assert_eq!( + ethereum_to_asc("x", &DynSolType::Uint(32), None), + "x.toBigInt().toI64()" + ); + assert_eq!( + ethereum_from_asc("x", &DynSolType::Int(40)), + "ethereum.Value.fromSignedBigInt(BigInt.fromI64(x))" + ); + assert_eq!( + ethereum_from_asc("x", &DynSolType::Uint(32)), + "ethereum.Value.fromUnsignedBigInt(BigInt.fromI64(x))" + ); + // Arrays of the mid-size band collapse to BigInt (no i64 array accessor), + // matching the `[BigInt!]` schema type. The declared type must collapse + // too, or it would not match the `toBigIntArray()` body. + let int40_array = DynSolType::Array(Box::new(DynSolType::Int(40))); + assert_eq!(asc_type_for_ethereum(&int40_array), "Array"); + assert_eq!( + ethereum_to_asc("x", &int40_array, None), + "x.toBigIntArray()" + ); + assert_eq!( + ethereum_from_asc("x", &int40_array), + "ethereum.Value.fromSignedBigIntArray(x)" + ); + // uint32 is a common type that now maps to the i64 band as a scalar; its + // array must not become `Array`. + let uint32_array = DynSolType::Array(Box::new(DynSolType::Uint(32))); + assert_eq!(asc_type_for_ethereum(&uint32_array), "Array"); + // Matrices collapse the same way. + let int40_matrix = DynSolType::Array(Box::new(int40_array.clone())); + assert_eq!(asc_type_for_ethereum(&int40_matrix), "Array>"); + assert_eq!( + ethereum_to_asc("x", &int40_matrix, None), + "x.toBigIntMatrix()" + ); + } + #[test] fn test_name_sanitization() { let generator = AbiCodeGenerator::new(JsonAbi::default(), "Test!Contract@Name"); @@ -1457,6 +1489,45 @@ mod tests { ); } + /// The scaffold (entity/handler names) and the ABI codegen (class names) must + /// assign the same event aliases — including escaping reserved words — or the + /// generated mapping imports a class under a name the bindings never export. + #[test] + fn test_event_aliases_agree_with_scaffold() { + // `await` is an AssemblyScript reserved word but a valid Solidity + // identifier, so both sides must escape it to `await_`. + let abi_json = r#"[ + {"type":"event","name":"await","inputs":[],"anonymous":false}, + {"type":"event","name":"await","inputs":[{"name":"x","type":"uint256","indexed":false}],"anonymous":false}, + {"type":"event","name":"Transfer","inputs":[],"anonymous":false} + ]"#; + let generator = AbiCodeGenerator::new(parse_abi(abi_json), "Token"); + let mut codegen: Vec = generator + .disambiguate_events() + .into_iter() + .map(|(_, alias)| alias) + .collect(); + + use crate::scaffold::manifest::{EventInfo, disambiguate_events}; + let ev = |name: &str| EventInfo { + name: name.to_string(), + signature: format!("{name}()"), + inputs: vec![], + }; + let mut scaffold: Vec = + disambiguate_events(vec![ev("await"), ev("await"), ev("Transfer")]) + .into_iter() + .map(|r| r.alias) + .collect(); + + // Compare as sets: alloy groups events by name, so the two may emit the + // aliases in a different order, but every event must get the same alias. + codegen.sort(); + scaffold.sort(); + assert_eq!(codegen, scaffold); + assert_eq!(codegen, vec!["Transfer", "await_", "await_1"]); + } + /// Test that overloaded events (same name, different inputs) are disambiguated. /// The TS CLI generates unique names like Transfer, Transfer1, Transfer2. #[test] diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index ef936884dfe..67d85af4277 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -334,29 +334,47 @@ fn schema_entity_types(project_dir: &Path, manifest: &serde_yaml::Value) -> Hash .and_then(|s| s.get("file")) .and_then(|f| f.as_str()) .unwrap_or("schema.graphql"); - let Ok(content) = fs::read_to_string(project_dir.join(schema_file)) else { - return HashSet::new(); + // A missing schema is normal for a fresh project, so stay quiet. A file that + // exists but can't be read or parsed silently limits collision detection to + // the manifest's entity lists, so warn about it. + let content = match fs::read_to_string(project_dir.join(schema_file)) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashSet::new(), + Err(e) => { + eprintln!( + "Warning: could not read {schema_file}: {e}; entity-name collision detection is limited" + ); + return HashSet::new(); + } }; - entity_types_in_schema(&content) + match entity_types_in_schema(&content) { + Some(types) => types, + None => { + eprintln!( + "Warning: could not parse {schema_file}; entity-name collision detection is limited" + ); + HashSet::new() + } + } } /// Parse GraphQL schema text and return the names of object types marked -/// `@entity`. Returns empty if the schema does not parse. -fn entity_types_in_schema(content: &str) -> HashSet { - let Ok(ast) = gql::parse_schema::(content) else { - return HashSet::new(); - }; - ast.definitions - .into_iter() - .filter_map(|def| match def { - gql::Definition::TypeDefinition(gql::TypeDefinition::Object(obj)) - if obj.directives.iter().any(|d| d.name == "entity") => - { - Some(obj.name) - } - _ => None, - }) - .collect() +/// `@entity`, or `None` if the schema does not parse. +fn entity_types_in_schema(content: &str) -> Option> { + let ast = gql::parse_schema::(content).ok()?; + Some( + ast.definitions + .into_iter() + .filter_map(|def| match def { + gql::Definition::TypeDefinition(gql::TypeDefinition::Object(obj)) + if obj.directives.iter().any(|d| d.name == "entity") => + { + Some(obj.name) + } + _ => None, + }) + .collect(), + ) } /// Resolve event names against the entities already present in the subgraph. @@ -386,15 +404,23 @@ fn resolve_events( // by name only, so a signature mismatch surfaces at codegen/build. r.declare_in_schema = false; } else { - let prefixed = format!("{}{}", contract_name, r.alias); - if existing.contains(&prefixed) { - return Err(anyhow!( - "Entity '{}' already exists; cannot rename '{}' to avoid a collision. Choose a different contract name.", - prefixed, - r.entity_name - )); - } - r.entity_name = prefixed; + r.entity_name = format!("{}{}", contract_name, r.alias); + } + } + + // No two declared entities may share a name, with each other or with an + // existing entity. This catches a batch sibling whose raw name equals another + // event's contract-prefixed rename: existing `Transfer` + an ABI with both + // `Transfer` and `TokenTransfer` would otherwise declare `TokenTransfer` + // twice. Overloads within one ABI are already separated by + // `disambiguate_events`, so they don't trip this. + let mut declared = existing.clone(); + for r in &resolved { + if r.declare_in_schema && !declared.insert(r.entity_name.clone()) { + return Err(anyhow!( + "Adding this contract would declare entity '{}' twice. Rename the contract or use --merge-entities.", + r.entity_name + )); } } @@ -719,6 +745,37 @@ mod tests { assert_eq!(resolved[1].entity_name, "TokenTransfer1"); } + #[test] + fn test_resolve_events_batch_sibling_collision_errors() { + // Existing `Transfer` renames the ABI's `Transfer` to `TokenTransfer`, + // which collides with the ABI's own `TokenTransfer`. That duplicate must + // be caught rather than silently declared twice. + let result = resolve_events( + vec![ev("Transfer"), ev("TokenTransfer")], + &entities(&["Transfer"]), + "Token", + false, + ); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_events_batch_sibling_collision_merge_ok() { + // With --merge-entities the existing `Transfer` is reused (not redeclared), + // so there is no duplicate and `TokenTransfer` is declared normally. + let resolved = resolve_events( + vec![ev("Transfer"), ev("TokenTransfer")], + &entities(&["Transfer"]), + "Token", + true, + ) + .unwrap(); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert!(!resolved[0].declare_in_schema); + assert_eq!(resolved[1].entity_name, "TokenTransfer"); + assert!(resolved[1].declare_in_schema); + } + #[test] fn test_entity_types_in_schema() { let schema = r#" @@ -726,11 +783,12 @@ mod tests { type Bar @entity(immutable: true) { id: Bytes! } type NotAnEntity { id: Bytes! } "#; - let types = entity_types_in_schema(schema); + let types = entity_types_in_schema(schema).expect("valid schema parses"); assert!(types.contains("Foo")); assert!(types.contains("Bar")); assert!(!types.contains("NotAnEntity")); - // A schema that doesn't parse yields no types rather than erroring. - assert!(entity_types_in_schema("this is not graphql {{{").is_empty()); + // A schema that doesn't parse yields `None` (so the caller can warn), + // rather than an empty set indistinguishable from an entity-less schema. + assert!(entity_types_in_schema("this is not graphql {{{").is_none()); } } diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index d8d433776ca..31684e0d502 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -124,18 +124,22 @@ pub struct ResolvedEvent { /// Resolve events for a fresh scaffold, disambiguating names that are overloaded /// within one ABI by suffixing repeats (`Transfer`, `Transfer1`, ...). There are /// no existing entities to collide with, so each entity is declared as-is. +/// +/// Names are escaped with `handle_reserved_word` before disambiguating, matching +/// the ABI codegen: the generated event class and the entity/handler imports must +/// use the same alias, or an event named after an AssemblyScript reserved word +/// (e.g. `await`) yields a schema/mapping that references a class the bindings +/// export under a different name. pub fn disambiguate_events(events: Vec) -> Vec { - let mut seen: HashMap = HashMap::new(); + let names: Vec = events + .iter() + .map(|e| handle_reserved_word(&e.name)) + .collect(); + let aliases = crate::shared::disambiguate_names(&names); events .into_iter() - .map(|event| { - let count = seen.entry(event.name.clone()).or_insert(0); - let alias = if *count == 0 { - event.name.clone() - } else { - format!("{}{}", event.name, count) - }; - *count += 1; + .zip(aliases) + .map(|(event, alias)| { let entity_name = alias.clone(); ResolvedEvent { event, @@ -211,9 +215,25 @@ pub fn flatten_event_inputs(inputs: &[EventParam]) -> Vec { &input.components, ); } + dedupe_field_names(&mut leaves); leaves } +/// Suffix repeated leaf field names (`value`, `value1`, ...) so the generated +/// entity never declares the same field twice. Two distinct params can sanitize +/// to the same field (`a-b` and `a_b` -> `a_b`), so the field side needs its own +/// dedup. Uses the shared allocator so a generated suffix skips a real field +/// (`value`, `value`, `value1` -> `value`, `value2`, `value1`). +fn dedupe_field_names(leaves: &mut [InputLeaf]) { + let names: Vec = leaves.iter().map(|leaf| leaf.field.clone()).collect(); + for (leaf, field) in leaves + .iter_mut() + .zip(crate::shared::disambiguate_names(&names)) + { + leaf.field = field; + } +} + fn flatten_into( out: &mut Vec, accessor_path: &[String], @@ -544,4 +564,47 @@ mod tests { assert_eq!(resolved[2].alias, "Approval"); assert!(resolved.iter().all(|r| r.declare_in_schema)); } + + #[test] + fn test_disambiguate_events_suffix_skips_real_name() { + let ev = |name: &str| EventInfo { + name: name.to_string(), + signature: format!("{}()", name), + inputs: vec![], + }; + // The second `Transfer` must not take `Transfer1`, which is a real event. + let resolved = disambiguate_events(vec![ev("Transfer"), ev("Transfer"), ev("Transfer1")]); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert_eq!(resolved[1].entity_name, "Transfer2"); + assert_eq!(resolved[2].entity_name, "Transfer1"); + } + + #[test] + fn test_flatten_dedupes_field_names_that_sanitize_alike() { + // Two distinct param names that sanitize to the same field (`a-b` and + // `a_b` both -> `a_b`) must not produce a duplicate entity field. + let param = |name: &str| EventParam { + name: name.to_string(), + ty: "uint256".to_string(), + ..Default::default() + }; + let leaves = flatten_event_inputs(&[param("a-b"), param("a_b")]); + let fields: Vec<&str> = leaves.iter().map(|l| l.field.as_str()).collect(); + assert_eq!(fields, vec!["a_b", "a_b1"]); + } + + #[test] + fn test_flatten_field_dedup_skips_a_real_field() { + // A generated suffix must not collide with a real later field: two + // unnamed params (both -> `value`) plus a param named `value1` must not + // yield two `value1`s. + let param = |name: &str| EventParam { + name: name.to_string(), + ty: "uint256".to_string(), + ..Default::default() + }; + let leaves = flatten_event_inputs(&[param(""), param(""), param("value1")]); + let fields: Vec<&str> = leaves.iter().map(|l| l.field.as_str()).collect(); + assert_eq!(fields, vec!["value", "value2", "value1"]); + } } diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index 541dc401858..8220c7ac252 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -1,6 +1,8 @@ //! Schema (schema.graphql) generation for scaffold. -use graph::abi::EventParam; +use graph::abi::{DynSolType, EventParam}; + +use crate::abi::{IntWidth, classify_int_width}; use super::ScaffoldOptions; use super::manifest::extract_events_from_abi; @@ -91,6 +93,11 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { "Bytes" => "[Bytes!]", "BigInt" => "[BigInt!]", "Int" => "[Int!]", + // Mid-size int arrays stay `[BigInt!]`: `ethereum.Value` has no i64 + // array accessor, so `Int8` applies to scalar ints only. Matches the + // codegen, which decodes int arrays of this band via `toBigIntArray()` + // (see codegen/abi.rs). + "Int8" => "[BigInt!]", "String" => "[String!]", "Boolean" => "[Boolean!]", _ => "[Bytes!]", @@ -123,27 +130,23 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { } } -/// Map a Solidity integer type to GraphQL `Int` when it fits in an i32, else -/// `BigInt`. An i32 holds signed ints up to 32 bits and unsigned ints up to 24 -/// bits, matching graph-cli's AssemblyScript type conversion. +/// Map a Solidity integer type to the narrowest GraphQL scalar that holds it: +/// `Int` (i32), `Int8` (i64), or `BigInt`. Parses the type with alloy (the same +/// parser the codegen uses) and classifies the width; see +/// [`classify_int_width`] for the cutoffs. Anything that isn't an integer type +/// falls back to `BigInt`. fn int_to_graphql(solidity_type: &str) -> &'static str { - let (signed, width) = match solidity_type.strip_prefix("uint") { - Some(rest) => (false, rest), - None => match solidity_type.strip_prefix("int") { - Some(rest) => (true, rest), - None => return "BigInt", - }, - }; - - // A bare `int` / `uint` is 256 bits. - let bits: u32 = if width.is_empty() { - 256 - } else { - width.parse().unwrap_or(256) + let (signed, bits) = match solidity_type.parse::() { + Ok(DynSolType::Int(bits)) => (true, bits as u32), + Ok(DynSolType::Uint(bits)) => (false, bits as u32), + _ => return "BigInt", }; - let fits_i32 = if signed { bits <= 32 } else { bits <= 24 }; - if fits_i32 { "Int" } else { "BigInt" } + match classify_int_width(signed, bits) { + IntWidth::I32 => "Int", + IntWidth::I64 => "Int8", + IntWidth::Big => "BigInt", + } } #[cfg(test)] @@ -257,19 +260,26 @@ mod tests { #[test] fn test_integer_width_mapping() { - // Small widths that fit in an i32 map to Int. + // Widths that fit an i32 -> Int. assert_eq!(solidity_to_graphql("int8"), "Int"); assert_eq!(solidity_to_graphql("int32"), "Int"); assert_eq!(solidity_to_graphql("uint8"), "Int"); assert_eq!(solidity_to_graphql("uint24"), "Int"); - // Wider integers need BigInt (uint32 does not fit an i32). - assert_eq!(solidity_to_graphql("uint32"), "BigInt"); - assert_eq!(solidity_to_graphql("int40"), "BigInt"); + // Wider widths that still fit an i64 -> Int8. + assert_eq!(solidity_to_graphql("uint32"), "Int8"); // first unsigned to overflow i32 + assert_eq!(solidity_to_graphql("int40"), "Int8"); + assert_eq!(solidity_to_graphql("int64"), "Int8"); + assert_eq!(solidity_to_graphql("uint56"), "Int8"); // largest unsigned that fits i64 + // Too wide for i64 -> BigInt. + assert_eq!(solidity_to_graphql("int72"), "BigInt"); + assert_eq!(solidity_to_graphql("uint64"), "BigInt"); // 2^64-1 overflows i64 assert_eq!(solidity_to_graphql("uint256"), "BigInt"); assert_eq!(solidity_to_graphql("int"), "BigInt"); assert_eq!(solidity_to_graphql("uint"), "BigInt"); - // Arrays follow the element type. + // Arrays: the i32 band keeps Int; the Int8 band collapses to BigInt (no + // i64 array accessor), and wider stays BigInt. assert_eq!(solidity_to_graphql("int8[]"), "[Int!]"); + assert_eq!(solidity_to_graphql("int40[]"), "[BigInt!]"); assert_eq!(solidity_to_graphql("uint64[]"), "[BigInt!]"); } diff --git a/gnd/src/shared/mod.rs b/gnd/src/shared/mod.rs index 4421dbd4645..83eb1ddc4e9 100644 --- a/gnd/src/shared/mod.rs +++ b/gnd/src/shared/mod.rs @@ -3,6 +3,8 @@ //! This module contains common utilities used across multiple codegen //! and scaffold modules to reduce duplication. +pub mod naming; pub mod sanitize; +pub use naming::disambiguate_names; pub use sanitize::{RESERVED_WORDS, capitalize, handle_reserved_word}; diff --git a/gnd/src/shared/naming.rs b/gnd/src/shared/naming.rs new file mode 100644 index 00000000000..a4da7f8d967 --- /dev/null +++ b/gnd/src/shared/naming.rs @@ -0,0 +1,69 @@ +//! Alias allocation for overloaded names. +//! +//! Events (and functions) can share a name within one ABI. Both the scaffold +//! (entity names) and the codegen (class names) disambiguate them by suffixing +//! repeats and must agree, so they share this helper. + +use std::collections::HashSet; + +/// Assign a unique alias to each name, in order. The first use of a name keeps +/// it; repeats get the lowest free numeric suffix (`name1`, `name2`, ...) that +/// isn't a real name in the list or an alias already handed out. So `Transfer`, +/// `Transfer`, `Transfer1` becomes `Transfer`, `Transfer2`, `Transfer1` rather +/// than two `Transfer1`s. +pub fn disambiguate_names(names: &[String]) -> Vec { + let reserved: HashSet<&str> = names.iter().map(String::as_str).collect(); + let mut used: HashSet = HashSet::new(); + names + .iter() + .map(|name| { + // First occurrence keeps the name; a real name is never taken as a + // suffix (the loop below skips `reserved`), so it's free here. + if used.insert(name.clone()) { + return name.clone(); + } + let mut n = 1; + loop { + let candidate = format!("{name}{n}"); + n += 1; + if !reserved.contains(candidate.as_str()) && used.insert(candidate.clone()) { + return candidate; + } + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn suffixes_repeats() { + assert_eq!( + disambiguate_names(&names(&["Transfer", "Transfer"])), + names(&["Transfer", "Transfer1"]) + ); + } + + #[test] + fn suffix_skips_a_real_name() { + // The second `Transfer` must not take `Transfer1`, which is a real event. + assert_eq!( + disambiguate_names(&names(&["Transfer", "Transfer", "Transfer1"])), + names(&["Transfer", "Transfer2", "Transfer1"]) + ); + } + + #[test] + fn distinct_names_unchanged() { + assert_eq!( + disambiguate_names(&names(&["Approval", "Transfer"])), + names(&["Approval", "Transfer"]) + ); + } +}