diff --git a/gnd/src/abi.rs b/gnd/src/abi.rs index 08948367dd6..5b9cd402a55 100644 --- a/gnd/src/abi.rs +++ b/gnd/src/abi.rs @@ -54,6 +54,16 @@ pub fn resolve_event_param_type(param: &EventParam) -> DynSolType { .expect("valid ABI type") } +/// Parse a selector type without panicking. +/// +/// [`resolve_event_param_type`] `.expect()`s because codegen only runs on ABIs +/// that already resolved. Scaffolding also runs on hand-written ABIs that alloy +/// accepts but cannot fully resolve (a component-less `tuple`), so it must fall +/// back to a `Bytes` field rather than abort. Returns `None` for such a type. +pub fn try_resolve_selector_type(selector_type: &str) -> Option { + selector_type.parse::().ok() +} + /// The type an indexed param actually carries in the log. /// /// A topic is exactly 32 bytes, so reference types (strings, bytes, arrays and diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index 31684e0d502..aad26eba2de 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use graph::abi::{Event, EventParam, Param}; +use graph::abi::{DynSolType, Event, EventParam, Param}; use super::ScaffoldOptions; use crate::shared::handle_reserved_word; @@ -179,12 +179,12 @@ pub fn event_param_accessors(inputs: &[EventParam]) -> Vec { } /// A flattened leaf of an event input: the entity field name, the matching -/// `event.params` accessor, and the leaf's Solidity type. A single `tuple` +/// `event.params` accessor, and the leaf's resolved type. A single `tuple` /// expands to one leaf per component (`data` -> `data_a`, `data_b`). pub struct InputLeaf { pub field: String, pub accessor: String, - pub solidity_type: String, + pub ty: DynSolType, } /// Flatten an event's inputs into leaves, unrolling a single `tuple` into its @@ -203,7 +203,7 @@ pub fn flatten_event_inputs(inputs: &[EventParam]) -> Vec { leaves.push(InputLeaf { field: super::sanitize_field_name(&input.name), accessor: accessor.clone(), - solidity_type: "bytes32".to_string(), + ty: DynSolType::FixedBytes(32), }); continue; } @@ -211,7 +211,7 @@ pub fn flatten_event_inputs(inputs: &[EventParam]) -> Vec { &mut leaves, std::slice::from_ref(accessor), &[super::sanitize_field_name(&input.name)], - &input.ty, + &input.selector_type(), &input.components, ); } @@ -238,14 +238,24 @@ fn flatten_into( out: &mut Vec, accessor_path: &[String], field_path: &[String], - solidity_type: &str, + selector_type: &str, components: &[Param], ) { - if solidity_type != "tuple" { + // `Tuple(_)` is the unroll discriminator; its payload is never read (names and + // recursion come from `components`), so dropping the component names alloy does + // is harmless. `tuple[]` resolves to `Array(Tuple(_))` and stays a leaf. + let resolved = crate::abi::try_resolve_selector_type(selector_type); + if !matches!(resolved, Some(DynSolType::Tuple(_))) { + let ty = resolved.unwrap_or_else(|| { + // alloy accepted the ABI but can't resolve this type (a component-less + // `tuple`); emit a Bytes leaf rather than abort scaffolding. + eprintln!("warning: scaffold could not resolve type `{selector_type}`, using Bytes"); + DynSolType::Bytes + }); out.push(InputLeaf { field: field_path.join("_"), accessor: accessor_path.join("."), - solidity_type: solidity_type.to_string(), + ty, }); return; } @@ -260,7 +270,13 @@ fn flatten_into( accessor.push(raw); let mut fields = field_path.to_vec(); fields.push(field); - flatten_into(out, &accessor, &fields, &comp.ty, &comp.components); + flatten_into( + out, + &accessor, + &fields, + &comp.selector_type(), + &comp.components, + ); } } @@ -607,4 +623,21 @@ mod tests { let fields: Vec<&str> = leaves.iter().map(|l| l.field.as_str()).collect(); assert_eq!(fields, vec!["value", "value2", "value1"]); } + + #[test] + fn test_flatten_component_less_tuple_falls_back_to_bytes() { + // alloy accepts a `tuple` with no components (solc never emits one), whose + // selector type is the bare "tuple" and does not resolve. Scaffolding must + // fall back to a single Bytes leaf, not panic like codegen's resolver. + let input = EventParam { + name: "data".to_string(), + ty: "tuple".to_string(), + components: vec![], + ..Default::default() + }; + let leaves = flatten_event_inputs(&[input]); + assert_eq!(leaves.len(), 1); + assert_eq!(leaves[0].field, "data"); + assert_eq!(leaves[0].ty, DynSolType::Bytes); + } } diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index af4e97ad566..77c1e6b620d 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -1,5 +1,7 @@ //! Mapping (AssemblyScript) generation for scaffold. +use graph::abi::DynSolType; + use super::ScaffoldOptions; use super::manifest::{EventInfo, ResolvedEvent, extract_events_from_abi}; use super::sanitize_field_name; @@ -205,7 +207,7 @@ fn generate_single_handler(resolved: &ResolvedEvent) -> String { // param for unnamed). let mut field_assignments = String::new(); for leaf in super::flatten_event_inputs(&resolved.event.inputs) { - if needs_bytes_array_cast(&leaf.solidity_type) { + if needs_bytes_array_cast(&leaf.ty) { field_assignments.push_str(&format!( " entity.{} = changetype(event.params.{})\n", leaf.field, leaf.accessor @@ -234,16 +236,25 @@ fn generate_single_handler(resolved: &ResolvedEvent) -> String { ) } -/// Whether a leaf is an `address[]`, whose binding type `Array
` needs a -/// `changetype` to fit a `Bytes[]` entity field. +/// Whether a leaf is an address array (`address[]` or `address[N]`), whose +/// binding type `Array
` needs a `changetype` to fit a `Bytes[]` entity +/// field. /// /// Only `address` qualifies: `Address extends Bytes`, so the cast is an upcast. /// `ethereum.Tuple` extends `Array` and is not a `Bytes`, so casting a /// `tuple[]` would be an unchecked reinterpret that compiles and then writes /// heap pointers into the entity. It has no `Bytes[]` form, so it gets no cast /// and fails to compile instead. -fn needs_bytes_array_cast(solidity_type: &str) -> bool { - solidity_type == "address[]" +/// +/// An indexed address array reaches the entity as a `bytes32` hash leaf, not an +/// array (`flatten_event_inputs` short-circuits it), so it never gets here and +/// this cast cannot reinterpret a hash. +fn needs_bytes_array_cast(ty: &DynSolType) -> bool { + matches!( + ty, + DynSolType::Array(inner) | DynSolType::FixedArray(inner, _) + if matches!(**inner, DynSolType::Address) + ) } /// Extract callable functions from ABI for documentation comments. @@ -522,6 +533,55 @@ mod tests { ); } + #[test] + fn test_generate_mapping_casts_fixed_size_address_array() { + // A non-indexed `address[N]` reaches the entity as an array, so it needs + // the same upcast as `address[]`; a fixed uint array does not. + let abi = json!([ + { + "type": "event", + "name": "Signers", + "inputs": [ + {"name": "owners", "type": "address[3]"}, + {"name": "amounts", "type": "uint256[3]"} + ] + } + ]); + + let options = ScaffoldOptions { + contract_name: "Vault".to_string(), + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let mapping = generate_mapping(&options); + assert!( + mapping.contains("entity.owners = changetype(event.params.owners)"), + "{}", + mapping + ); + assert!( + mapping.contains("entity.amounts = event.params.amounts\n"), + "{}", + mapping + ); + } + + #[test] + fn test_needs_bytes_array_cast() { + let cast = |t: &str| needs_bytes_array_cast(&t.parse::().unwrap()); + // Both dynamic and fixed-size address arrays need the upcast to Bytes[]. + assert!(cast("address[]")); + assert!(cast("address[3]")); + // Nothing else does: a plain address, a non-address array, or a tuple + // array (which has no Bytes[] form and must fail to compile instead). + assert!(!cast("address")); + assert!(!cast("uint256[]")); + assert!(!cast("uint256[3]")); + assert!(!cast("(address,uint256)[]")); + } + #[test] fn test_generate_mapping_indexed_reference_params_are_hashes() { // Indexed reference types are keccak'd into a topic, so the binding diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index 8220c7ac252..20835525a41 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -45,10 +45,16 @@ fn generate_example_entity(inputs: &[EventParam]) -> String { // Include first 2 event params with type comments for input in inputs.iter().take(2) { let field_name = sanitize_field_name(&input.name); - let graphql_type = solidity_to_graphql(&input.ty); + // Resolve from `selector_type` (a tuple's `ty` is the bare "tuple"), but + // keep the `# {ty}` comment on the raw declared type. Bytes is the + // fallback for a type alloy can't resolve. + let ty = crate::abi::try_resolve_selector_type(&input.selector_type()) + .unwrap_or(DynSolType::Bytes); fields.push_str(&format!( " {}: {}! # {}\n", - field_name, graphql_type, input.ty + field_name, + graphql_type(&ty), + input.ty )); } @@ -68,8 +74,7 @@ pub fn generate_event_entity(entity_name: &str, inputs: &[EventParam]) -> String // Fields from event inputs (tuples are unrolled into a field per component). for leaf in super::flatten_event_inputs(inputs) { - let graphql_type = solidity_to_graphql(&leaf.solidity_type); - fields.push_str(&format!(" {}: {}!\n", leaf.field, graphql_type)); + fields.push_str(&format!(" {}: {}!\n", leaf.field, graphql_type(&leaf.ty))); } // Standard blockchain fields @@ -84,64 +89,49 @@ pub fn generate_event_entity(entity_name: &str, inputs: &[EventParam]) -> String ) } -/// Convert Solidity type to GraphQL type. -fn solidity_to_graphql(solidity_type: &str) -> &'static str { - // Handle arrays - if solidity_type.ends_with("[]") { - let inner = &solidity_type.strip_suffix("[]").unwrap(); - return match solidity_to_graphql(inner) { - "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!]", - }; +/// Map a resolved Solidity type to the GraphQL scalar the scaffold emits. +/// +/// The match is exhaustive on purpose: enabling alloy's `eip712` feature adds a +/// `CustomStruct` variant, and a missing arm should fail to compile rather than +/// silently fall through. +fn graphql_type(ty: &DynSolType) -> &'static str { + match ty { + DynSolType::Address | DynSolType::Bytes | DynSolType::FixedBytes(_) => "Bytes", + // A Solidity `function` (bytes24). codegen decodes it as `ethereum.Tuple` + // (`.toTuple()`), so schema and binding disagree; kept as the pre-existing + // scaffold behavior, tracked in #6684. + DynSolType::Function => "Bytes", + DynSolType::Bool => "Boolean", + DynSolType::String => "String", + DynSolType::Int(bits) => int_scalar(true, *bits as u32), + DynSolType::Uint(bits) => int_scalar(false, *bits as u32), + // An indexed reference type arrives as a `bytes32` hash leaf, and a + // placeholder tuple renders opaque: either way one `Bytes` field. + DynSolType::Tuple(_) => "Bytes", + DynSolType::Array(inner) | DynSolType::FixedArray(inner, _) => list_of(inner), } +} - match solidity_type { - // Address types - "address" => "Bytes", - - // Boolean - "bool" => "Boolean", - - // String - "string" => "String", - - // Bytes types - "bytes" => "Bytes", - "bytes1" | "bytes2" | "bytes3" | "bytes4" | "bytes5" | "bytes6" | "bytes7" | "bytes8" - | "bytes9" | "bytes10" | "bytes11" | "bytes12" | "bytes13" | "bytes14" | "bytes15" - | "bytes16" | "bytes17" | "bytes18" | "bytes19" | "bytes20" | "bytes21" | "bytes22" - | "bytes23" | "bytes24" | "bytes25" | "bytes26" | "bytes27" | "bytes28" | "bytes29" - | "bytes30" | "bytes31" | "bytes32" => "Bytes", - - // Integers: small widths fit in an i32 (GraphQL Int), the rest need BigInt. - t if t.starts_with("uint") || t.starts_with("int") => int_to_graphql(t), - - // Default to Bytes for unknown types - _ => "Bytes", +/// The GraphQL list type for an array element. The `Int8` (i64) band collapses to +/// `[BigInt!]`: `ethereum.Value` has no i64 array accessor, so the codegen decodes +/// int arrays of that band via `toBigIntArray()` (see codegen/abi.rs). Nested +/// lists flatten to one dimension, matching graph-node's store, which has none. +fn list_of(inner: &DynSolType) -> &'static str { + match graphql_type(inner) { + "Int" => "[Int!]", + "Boolean" => "[Boolean!]", + "String" => "[String!]", + "Int8" | "BigInt" => "[BigInt!]", + // Bytes, and any nested list degraded to one dimension. + _ => "[Bytes!]", } } -/// 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, bits) = match solidity_type.parse::() { - Ok(DynSolType::Int(bits)) => (true, bits as u32), - Ok(DynSolType::Uint(bits)) => (false, bits as u32), - _ => return "BigInt", - }; - +/// Map a Solidity integer to the narrowest GraphQL scalar that holds it: +/// `Int` (i32), `Int8` (i64), or `BigInt`; see [`classify_int_width`] for the +/// cutoffs. Must match codegen's `asc_type_for_ethereum`, or the schema and the +/// generated bindings disagree. +fn int_scalar(signed: bool, bits: u32) -> &'static str { match classify_int_width(signed, bits) { IntWidth::I32 => "Int", IntWidth::I64 => "Int8", @@ -247,40 +237,108 @@ mod tests { assert!(schema.contains("value: BigInt!")); } + /// Resolve a Solidity type string and map it, exercising the same path the + /// generators use. + fn graphql_type_of(solidity_type: &str) -> &'static str { + graphql_type(&solidity_type.parse::().unwrap()) + } + #[test] - fn test_solidity_to_graphql() { - assert_eq!(solidity_to_graphql("address"), "Bytes"); - assert_eq!(solidity_to_graphql("bool"), "Boolean"); - assert_eq!(solidity_to_graphql("string"), "String"); - assert_eq!(solidity_to_graphql("bytes32"), "Bytes"); - assert_eq!(solidity_to_graphql("bytes"), "Bytes"); - assert_eq!(solidity_to_graphql("address[]"), "[Bytes!]"); - assert_eq!(solidity_to_graphql("uint256[]"), "[BigInt!]"); + fn test_graphql_type() { + assert_eq!(graphql_type_of("address"), "Bytes"); + assert_eq!(graphql_type_of("bool"), "Boolean"); + assert_eq!(graphql_type_of("string"), "String"); + assert_eq!(graphql_type_of("bytes32"), "Bytes"); + assert_eq!(graphql_type_of("bytes"), "Bytes"); + assert_eq!(graphql_type_of("address[]"), "[Bytes!]"); + assert_eq!(graphql_type_of("uint256[]"), "[BigInt!]"); } #[test] fn test_integer_width_mapping() { // 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"); + assert_eq!(graphql_type_of("int8"), "Int"); + assert_eq!(graphql_type_of("int32"), "Int"); + assert_eq!(graphql_type_of("uint8"), "Int"); + assert_eq!(graphql_type_of("uint24"), "Int"); // 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 + assert_eq!(graphql_type_of("uint32"), "Int8"); // first unsigned to overflow i32 + assert_eq!(graphql_type_of("int40"), "Int8"); + assert_eq!(graphql_type_of("int64"), "Int8"); + assert_eq!(graphql_type_of("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"); + assert_eq!(graphql_type_of("int72"), "BigInt"); + assert_eq!(graphql_type_of("uint64"), "BigInt"); // 2^64-1 overflows i64 + assert_eq!(graphql_type_of("uint256"), "BigInt"); + // Bare `uint`/`int` canonicalize to the 256-bit form. + assert_eq!(graphql_type_of("int"), "BigInt"); + assert_eq!(graphql_type_of("uint"), "BigInt"); // 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!]"); + assert_eq!(graphql_type_of("int8[]"), "[Int!]"); + assert_eq!(graphql_type_of("int40[]"), "[BigInt!]"); + assert_eq!(graphql_type_of("uint64[]"), "[BigInt!]"); + } + + #[test] + fn test_generate_schema_covers_all_type_mappings() { + // One event exercising every schema-side mapping row, including the + // fixed-size arrays the old `ends_with("[]")` matcher could not see and + // that scaffolded to non-building scalar fields. + let param = |name: &str, ty: &str| json!({"name": name, "type": ty, "indexed": false}); + let abi = json!([ + { + "type": "event", + "name": "AllTypes", + "inputs": [ + param("addr", "address"), + param("flag", "bool"), + param("text", "string"), + param("tiny", "uint8"), // i32 band + param("mid", "uint32"), // i64 band + param("big", "uint256"), // BigInt + param("addrsDyn", "address[]"), + param("addrsFixed", "address[3]"), + param("numsFixed", "uint256[3]"), + param("tinyFixed", "uint8[3]"), + param("flagsFixed", "bool[2]"), + param("textFixed", "string[2]"), + param("hashesFixed", "bytes32[4]"), + param("tinyDyn", "int8[]"), // i32 band keeps Int + param("midDyn", "int40[]"), // i64 band collapses to BigInt + ] + } + ]); + + let options = ScaffoldOptions { + abi: Some(abi), + index_events: true, + ..Default::default() + }; + let schema = generate_schema(&options); + + for (field, ty) in [ + ("addr", "Bytes!"), + ("flag", "Boolean!"), + ("text", "String!"), + ("tiny", "Int!"), + ("mid", "Int8!"), + ("big", "BigInt!"), + ("addrsDyn", "[Bytes!]!"), + ("addrsFixed", "[Bytes!]!"), + ("numsFixed", "[BigInt!]!"), + ("tinyFixed", "[Int!]!"), + ("flagsFixed", "[Boolean!]!"), + ("textFixed", "[String!]!"), + ("hashesFixed", "[Bytes!]!"), + ("tinyDyn", "[Int!]!"), + ("midDyn", "[BigInt!]!"), + ] { + assert!( + schema.contains(&format!("{field}: {ty}")), + "expected `{field}: {ty}` in:\n{schema}" + ); + } } #[test]