diff --git a/serde-generate/src/solidity.rs b/serde-generate/src/solidity.rs index 18211caa3..13b94739d 100644 --- a/serde-generate/src/solidity.rs +++ b/serde-generate/src/solidity.rs @@ -592,7 +592,7 @@ function bcs_serialize_string(string memory input) break; }} }} - bytes memory result_len = bcs_serialize_len(number_char); + bytes memory result_len = bcs_serialize_uleb128(number_char); return abi.encodePacked(result_len, input); }} @@ -603,7 +603,7 @@ function bcs_deserialize_offset_string(uint256 pos, bytes memory input) {{ uint256 len; uint256 new_pos; - (new_pos, len) = bcs_deserialize_offset_len(pos, input); + (new_pos, len) = bcs_deserialize_offset_uleb128(pos, input); uint256 shift = 0; for (uint256 i=0; i, + name: String, + value: Option, +} + +fn uleb128_encode(mut value: u64) -> Vec { + let mut bytes = Vec::new(); + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + bytes.push(byte); + return bytes; + } + bytes.push(byte | 0x80); + } +} + +fn hex_literal(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(2 + bytes.len() * 2 + 1); + s.push_str("hex\""); + for b in bytes { + write!(s, "{b:02x}").expect("writing to String is infallible"); + } + s.push('"'); + s +} + #[derive(Clone, Debug, PartialEq)] enum SolFormat { /// One of the primitive types defined elsewhere @@ -678,10 +718,13 @@ enum SolFormat { Option(Box), /// A Tuplearray encapsulated as a solidity struct. TupleArray { format: Box, size: usize }, - /// A complex enum encapsulated as a solidity struct. + /// A complex enum encapsulated as a solidity struct. `is_contiguous` is + /// precomputed at parse time so the codegen's validity check can be a + /// simple `choice < N` instead of a chained `||` over each `variant.index`. Enum { name: String, - formats: Vec>>, + variants: Vec, + is_contiguous: bool, }, /// A Tuplearray of N U8 has the native type bytesN BytesN { size: usize }, @@ -700,7 +743,11 @@ impl SolFormat { TupleArray { format, size } => format!("tuplearray{}_{}", size, format.key_name()), Struct { name, formats: _ } => name.to_string(), SimpleEnum { name, names: _ } => name.to_string(), - Enum { name, formats: _ } => name.to_string(), + Enum { + name, + variants: _, + is_contiguous: _, + } => name.to_string(), BytesN { size } => format!("bytes{size}"), OptionBool => "OptionBool".to_string(), } @@ -784,7 +831,7 @@ function bcs_serialize_{key_name}({code_name} memory input) returns (bytes memory) {{ uint256 len = input.length; - bytes memory result = bcs_serialize_len(len); + bytes memory result = bcs_serialize_uleb128(len); for (uint256 i=0; i { let names_join = names.join(", "); let number_names = names.len(); + // A Solidity enum is internally a `uint8`, and 0.8.x reverts on + // out-of-range integer-to-enum conversion. We still guard with + // `choice < N` first because `uint8(choice)` silently truncates, + // so a malformed multi-byte ULEB128 like `0x85 0x02` (=261) + // would otherwise sneak in as variant 5. writeln!( out, r#" @@ -944,7 +996,7 @@ function bcs_serialize_{name}({name} input) pure returns (bytes memory) {{ - return abi.encodePacked(input); + return bcs_serialize_uleb128(uint256(input)); }} function bcs_deserialize_offset_{name}(uint256 pos, bytes memory input) @@ -952,49 +1004,46 @@ function bcs_deserialize_offset_{name}(uint256 pos, bytes memory input) pure returns (uint256, {name}) {{ - uint8 choice = uint8(input[pos]);"# - )?; - for (idx, name_choice) in names.iter().enumerate() { - writeln!( - out, - r#" - if (choice == {idx}) {{ - return (pos + 1, {name}.{name_choice}); - }}"# - )?; - } - writeln!( - out, - r#" - require(choice < {number_names}); + uint256 new_pos; + uint256 choice; + (new_pos, choice) = bcs_deserialize_offset_uleb128(pos, input); + require(choice < {number_names}, "invalid variant index"); + return (new_pos, {name}(uint8(choice))); }}"# )?; output_generic_bcs_deserialize(out, name, name, false)?; } - Enum { name, formats } => { - let number_names = formats.len(); + Enum { + name, + variants, + is_contiguous, + } => { writeln!( out, r#" struct {name} {{ - uint8 choice;"# + uint64 choice;"# )?; - for (idx, named_format) in formats.iter().enumerate() { - let variant_name = named_format.name.clone(); - writeln!(out, " // choice={idx} corresponds to {variant_name}")?; - if let Some(format) = &named_format.value { + for variant in variants { + let variant_index = variant.index; + let variant_name = &variant.name; + writeln!( + out, + " // choice={variant_index} corresponds to {variant_name}" + )?; + if let Some(format) = &variant.value { let qualified_code_name = sol_registry.qualified_code_name(format); - let snake_name = safe_variable(&named_format.name.to_snake_case()); + let snake_name = safe_variable(&variant.name.to_snake_case()); writeln!(out, " {qualified_code_name} {snake_name};")?; } } writeln!(out, "}}")?; let mut entries = Vec::new(); let mut type_vars = Vec::new(); - for named_format in formats { - if let Some(format) = &named_format.value { + for variant in variants { + if let Some(format) = &variant.value { let data_location = sol_registry.data_location(format); - let snake_name = safe_variable(&named_format.name.to_snake_case()); + let snake_name = safe_variable(&variant.name.to_snake_case()); let qualified_code_name = sol_registry.qualified_code_name(format); let type_var = format!("{qualified_code_name}{data_location} {snake_name}"); type_vars.push(type_var); @@ -1003,10 +1052,19 @@ struct {name} {{ type_vars.push(String::new()); } } - let entries = entries.join(", "); - for (choice, named_format_i) in formats.iter().enumerate() { - let snake_name = named_format_i.name.to_snake_case(); - let type_var = &type_vars[choice]; + // If no variant carries a payload (sparse all-Unit enums or + // all-Unit enums with >256 variants both reach this path), the + // struct has only the `choice` field, so the suffix must be + // empty — otherwise we'd emit a trailing-comma `Foo(x, )`. + let entries_suffix = if entries.is_empty() { + String::new() + } else { + format!(", {}", entries.join(", ")) + }; + for (slot, variant) in variants.iter().enumerate() { + let snake_name = variant.name.to_snake_case(); + let type_var = &type_vars[slot]; + let variant_index = variant.index; writeln!( out, r#" @@ -1016,12 +1074,15 @@ function {name}_case_{snake_name}({type_var}) returns ({name} memory) {{"# )?; - for (i_choice, type_var) in type_vars.iter().enumerate() { - if !type_var.is_empty() && choice != i_choice { - writeln!(out, " {type_var};")?; + for (i_slot, other_type_var) in type_vars.iter().enumerate() { + if !other_type_var.is_empty() && slot != i_slot { + writeln!(out, " {other_type_var};")?; } } - writeln!(out, " return {name}(uint8({choice}), {entries});")?; + writeln!( + out, + " return {name}(uint64({variant_index}){entries_suffix});" + )?; writeln!(out, "}}")?; } writeln!( @@ -1033,19 +1094,26 @@ function bcs_serialize_{name}({name} memory input) returns (bytes memory) {{"# )?; - for (idx, named_format) in formats.iter().enumerate() { - if let Some(format) = &named_format.value { + for variant in variants { + let variant_index = variant.index; + let discriminant_hex = hex_literal(&variant.uleb128); + writeln!(out, " if (input.choice == {variant_index}) {{")?; + if let Some(format) = &variant.value { let key_name = format.key_name(); - let snake_name = safe_variable(&named_format.name.to_snake_case()); + let snake_name = safe_variable(&variant.name.to_snake_case()); let ser_fn = sol_registry.qualified_fn_name("bcs_serialize", &key_name); - writeln!(out, " if (input.choice == {idx}) {{")?; - writeln!(out, " return abi.encodePacked(input.choice, {ser_fn}(input.{snake_name}));")?; - writeln!(out, " }}")?; + writeln!( + out, + " return abi.encodePacked({discriminant_hex}, {ser_fn}(input.{snake_name}));" + )?; + } else { + writeln!(out, " return {discriminant_hex};")?; } + writeln!(out, " }}")?; } writeln!( out, - r#" return abi.encodePacked(input.choice); + r#" revert("invalid variant index"); }} function bcs_deserialize_offset_{name}(uint256 pos, bytes memory input) @@ -1054,36 +1122,48 @@ function bcs_deserialize_offset_{name}(uint256 pos, bytes memory input) returns (uint256, {name} memory) {{ uint256 new_pos; - uint8 choice; - (new_pos, choice) = bcs_deserialize_offset_uint8(pos, input);"# + uint256 choice_raw; + (new_pos, choice_raw) = bcs_deserialize_offset_uleb128(pos, input); + require(choice_raw <= type(uint64).max, "variant index does not fit in uint64"); + uint64 choice = uint64(choice_raw);"# )?; - let mut entries = Vec::new(); - for (idx, named_format) in formats.iter().enumerate() { - if let Some(format) = &named_format.value { + let validity_check = if *is_contiguous { + format!("choice < {}", variants.len()) + } else { + variants + .iter() + .map(|v| format!("choice == {}", v.index)) + .collect::>() + .join(" || ") + }; + writeln!( + out, + " require({validity_check}, \"invalid variant index\");" + )?; + for variant in variants { + if let Some(format) = &variant.value { let data_location = sol_registry.data_location(format); - let snake_name = safe_variable(&named_format.name.to_snake_case()); + let snake_name = safe_variable(&variant.name.to_snake_case()); let qualified_code_name = sol_registry.qualified_code_name(format); let key_name = format.key_name(); let deser_fn = sol_registry.qualified_fn_name("bcs_deserialize_offset", &key_name); + let variant_index = variant.index; writeln!( out, " {qualified_code_name}{data_location} {snake_name};" )?; - writeln!(out, " if (choice == {idx}) {{")?; + writeln!(out, " if (choice == {variant_index}) {{")?; writeln!( out, " (new_pos, {snake_name}) = {deser_fn}(new_pos, input);" )?; writeln!(out, " }}")?; - entries.push(snake_name); } } - writeln!(out, " require(choice < {number_names});")?; - let entries = entries.join(", "); writeln!( out, - r#" return (new_pos, {name}(choice, {entries})); + r#" return (new_pos, {name}(choice{entries_suffix})); }}"# )?; output_generic_bcs_deserialize(out, name, name, true)?; @@ -1185,18 +1265,20 @@ function bcs_deserialize_offset_{name}(uint256 pos, bytes memory input) // Option deserializer calls bcs_deserialize_offset_bool for the tag. Option(format) => vec![format.key_name(), "bool".to_string()], TupleArray { format, size: _ } => vec![format.key_name()], - // Enum deserializer calls bcs_deserialize_offset_uint8 for the choice tag. - Enum { name: _, formats } => { - let mut deps: Vec = formats - .iter() - .flat_map(|format| match &format.value { - None => vec![], - Some(format) => vec![format.key_name()], - }) - .collect(); - deps.push("uint8".to_string()); - deps - } + // Variant index bytes are precomputed (hex literal on serialize) and + // decoded via the preamble's `bcs_deserialize_offset_uleb128` helper, so + // the enum's own dependencies are just the payload-bearing variants. + Enum { + name: _, + variants, + is_contiguous: _, + } => variants + .iter() + .flat_map(|variant| match &variant.value { + None => vec![], + Some(format) => vec![format.key_name()], + }) + .collect(), BytesN { size: _ } => vec![], OptionBool => vec![], } @@ -1422,6 +1504,20 @@ impl SolRegistry { fn parse_container_format(&mut self, container_format: Named) { use ContainerFormat::*; let name = container_format.name; + // Container names must start with an ASCII uppercase letter. This is + // what guarantees that user-supplied names can never collide with the + // generated lowercase prefixes (`bcs_serialize_`, `bcs_deserialize_`, + // `bcs_deserialize_offset_`, `seq_`, `opt_`, ...) in function names — + // e.g. without it, a registry containing both `Foo` and `offset_Foo` + // would emit the same `bcs_deserialize_offset_Foo` for `Foo`'s + // positional deserializer and `offset_Foo`'s root. Real Rust type + // names are PascalCase, so for any registry built from real types this + // is a no-op; the check exists to catch synthetic registries. + assert!( + name.chars().next().is_some_and(|c| c.is_ascii_uppercase()), + "Solidity container name `{name}` must start with an ASCII uppercase letter \ + to avoid collisions with generated lowercase prefixes." + ); let sol_format = match container_format.value { UnitStruct => panic!("UnitStruct is not supported in solidity"), NewTypeStruct(format) => { @@ -1459,24 +1555,29 @@ impl SolRegistry { !map.is_empty(), "The enum should be non-trivial in solidity" ); - assert!(map.len() < 256, "The enum should have at most 256 entries"); let is_trivial = map .iter() .all(|(_, v)| matches!(v.value, VariantFormat::Unit)); - if is_trivial { + // The native-Solidity SimpleEnum path uses the Solidity enum's + // own discriminant (positional 0..N-1), so it can only be used + // when the BCS variant indices are exactly 0,1,...,N-1. + let is_contiguous = map + .keys() + .enumerate() + .all(|(i, k)| u64::from(*k) == i as u64); + if is_trivial && is_contiguous && map.len() <= 256 { + // Solidity native enums are limited to 256 entries. let names = map .into_values() .map(|named_format| named_format.name) .collect(); SolFormat::SimpleEnum { name, names } } else { - let choice_sol_format = SolFormat::Primitive(Primitive::U8); - self.insert(choice_sol_format); - let mut formats = Vec::new(); - for (_key, value) in map { + let mut variants = Vec::new(); + for (key, value) in map { use VariantFormat::*; - let name_red = value.name; - let concat_name = format!("{name}_{name_red}"); + let variant_name = value.name; + let concat_name = format!("{name}_{variant_name}"); let entry = match value.value { VariantFormat::Unit => None, NewType(format) => Some(self.parse_format(*format)), @@ -1494,13 +1595,19 @@ impl SolRegistry { Struct(formats) => Some(self.parse_struct_format(concat_name, formats)), Variable(_) => panic!("Variable is not supported for solidity"), }; - let format = Named { - name: name_red, + let index = u64::from(key); + variants.push(EnumVariant { + index, + uleb128: uleb128_encode(index), + name: variant_name, value: entry, - }; - formats.push(format); + }); + } + SolFormat::Enum { + name, + variants, + is_contiguous, } - SolFormat::Enum { name, formats } } } }; @@ -1526,7 +1633,8 @@ impl SolRegistry { SimpleEnum { name: _, names: _ } => false, Enum { name: _, - formats: _, + variants: _, + is_contiguous: _, } => true, BytesN { size: _ } => false, OptionBool => false, @@ -1583,8 +1691,9 @@ impl SolRegistry { needed } - /// Returns true if any locally-needed type uses the `bcs_serialize_len` / - /// `bcs_deserialize_offset_len` preamble functions (Seq, Str, Bytes). + /// Returns true if any locally-needed type uses the `bcs_serialize_uleb128` / + /// `bcs_deserialize_offset_uleb128` preamble functions: Seq, Str, Bytes use them + /// for length prefixes, and Enum / SimpleEnum use them for variant indices. fn needs_preamble(&self, needed: &HashSet) -> bool { needed.iter().any(|key| { self.names.get(key).is_some_and(|f| { @@ -1593,6 +1702,8 @@ impl SolRegistry { SolFormat::Seq(_) | SolFormat::Primitive(Primitive::Str) | SolFormat::Primitive(Primitive::Bytes) + | SolFormat::Enum { .. } + | SolFormat::SimpleEnum { .. } ) }) }) @@ -1698,7 +1809,7 @@ pragma solidity ^0.8.0;"# writeln!( self.out, r#" -function bcs_serialize_len(uint256 x) +function bcs_serialize_uleb128(uint256 x) internal pure returns (bytes memory) @@ -1722,7 +1833,7 @@ function bcs_serialize_len(uint256 x) return result; }} -function bcs_deserialize_offset_len(uint256 pos, bytes memory input) +function bcs_deserialize_offset_uleb128(uint256 pos, bytes memory input) internal pure returns (uint256, uint256) diff --git a/serde-generate/tests/solidity_generation.rs b/serde-generate/tests/solidity_generation.rs index 1b40fdfad..12f693b4a 100644 --- a/serde-generate/tests/solidity_generation.rs +++ b/serde-generate/tests/solidity_generation.rs @@ -823,3 +823,24 @@ fn test_external_definitions_rejects_invalid_module_name() { )); let _ = generate_solidity(&config, ®istry); } + +#[test] +#[should_panic(expected = "must start with an ASCII uppercase letter")] +fn test_rejects_lowercase_container_name() { + use serde_reflection::{ContainerFormat, Format, Named}; + + // A lowercase container name could collide with generated lowercase + // prefixes (e.g. `offset_Foo` vs `Foo`'s `bcs_deserialize_offset_Foo`), + // so the codegen rejects it up front. + let mut registry = Registry::new(); + registry.insert( + "offset_foo".into(), + ContainerFormat::Struct(vec![Named { + name: "x".into(), + value: Format::U64, + }]), + ); + + let config = CodeGeneratorConfig::new("Test".into()); + let _ = generate_solidity(&config, ®istry); +} diff --git a/serde-generate/tests/solidity_runtime.rs b/serde-generate/tests/solidity_runtime.rs index b2a0edad2..ab196e1a6 100644 --- a/serde-generate/tests/solidity_runtime.rs +++ b/serde-generate/tests/solidity_runtime.rs @@ -415,6 +415,460 @@ contract ExampleCode {{ Ok(()) } +/// Regression test for ULEB128-encoded variant indices: an enum with >= 128 +/// variants forces the discriminant to use more than one byte, exercising the +/// fix that made `choice` a `uint64` encoded/decoded via `bcs_serialize_len` / +/// `bcs_deserialize_offset_len` instead of a single `uint8` byte. +#[test] +fn test_enum_uleb128_variant_index() -> anyhow::Result<()> { + use serde_reflection::{ContainerFormat, Format, Named, Registry, VariantFormat}; + use std::collections::BTreeMap; + + // 199 Unit variants + 1 NewType(u32) variant at index 199. The non-trivial + // tail forces the complex (struct-backed) Enum codepath in the generator. + let mut variants: BTreeMap> = BTreeMap::new(); + for i in 0..199u32 { + variants.insert( + i, + Named { + name: format!("V{i}"), + value: VariantFormat::Unit, + }, + ); + } + variants.insert( + 199, + Named { + name: "WithPayload".into(), + value: VariantFormat::NewType(Box::new(Format::U32)), + }, + ); + + let mut registry = Registry::new(); + registry.insert("BigEnum".into(), ContainerFormat::Enum(variants)); + + let dir = tempdir().unwrap(); + let path = dir.path(); + + let test_library_path = path.join("Library.sol"); + { + let mut test_library_file = File::create(&test_library_path)?; + let config = CodeGeneratorConfig::new("Library".to_string()); + let generator = solidity::CodeGenerator::new(&config); + generator.output(&mut test_library_file, ®istry).unwrap(); + } + + let test_code_path = path.join("test_code.sol"); + { + let mut test_code_file = File::create(&test_code_path)?; + writeln!( + test_code_file, + r#"/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Library.sol"; + +contract ExampleCode {{ + + function test_round_trip(bytes calldata input, uint64 expected_choice) external {{ + Library.BigEnum memory t = Library.bcs_deserialize_BigEnum(input); + require(t.choice == expected_choice, "wrong choice"); + bytes memory input_rev = Library.bcs_serialize_BigEnum(t); + require(input.length == input_rev.length, "length mismatch"); + for (uint256 i=0; i, u64)> = vec![ + (vec![0x00], 0), + (vec![0x7f], 127), + (vec![0x80, 0x01], 128), + ( + { + let mut v = vec![0xc7, 0x01]; + v.extend_from_slice(&0xdead_beef_u32.to_le_bytes()); + v + }, + 199, + ), + ]; + + for (input_bytes, expected_choice) in cases { + let input = Bytes::copy_from_slice(&input_bytes); + let fct_args = test_round_tripCall { + input, + expected_choice, + }; + let fct_args = fct_args.abi_encode().into(); + test_contract(bytecode.clone(), fct_args); + } + + Ok(()) +} + +/// Regression test for sparse Serde variant indices. +/// +/// `ContainerFormat::Enum` is a `BTreeMap`, so the variant indices +/// are not required to be contiguous `0..N-1`. The generator must preserve +/// each variant's original index in the BCS encoding (rather than re-numbering +/// them by position). This test checks dispatch, validation, and the +/// precomputed multi-byte ULEB128 discriminant for a NewType variant at a +/// sparse, multi-byte index. +#[test] +fn test_enum_sparse_variant_indices() -> anyhow::Result<()> { + use serde_reflection::{ContainerFormat, Format, Named, Registry, VariantFormat}; + use std::collections::BTreeMap; + + let mut variants: BTreeMap> = BTreeMap::new(); + variants.insert( + 0, + Named { + name: "Zero".into(), + value: VariantFormat::Unit, + }, + ); + variants.insert( + 5, + Named { + name: "Five".into(), + value: VariantFormat::Unit, + }, + ); + variants.insert( + 128, + Named { + name: "OneTwentyEight".into(), + value: VariantFormat::Unit, + }, + ); + variants.insert( + 300, + Named { + name: "WithPayload".into(), + value: VariantFormat::NewType(Box::new(Format::U32)), + }, + ); + + let mut registry = Registry::new(); + registry.insert("Sparse".into(), ContainerFormat::Enum(variants)); + + let dir = tempdir().unwrap(); + let path = dir.path(); + + let test_library_path = path.join("Library.sol"); + { + let mut test_library_file = File::create(&test_library_path)?; + let config = CodeGeneratorConfig::new("Library".to_string()); + let generator = solidity::CodeGenerator::new(&config); + generator.output(&mut test_library_file, ®istry).unwrap(); + } + + // Spot-check the generated source: discriminant 300 = ULEB128 0xac 0x02. + let generated = std::fs::read_to_string(&test_library_path)?; + assert!( + generated.contains(r#"hex"ac02""#), + "generator should embed the precomputed ULEB128 for index 300 (0xac 0x02):\n{generated}" + ); + // Index 5 collapses to a single byte 0x05. + assert!( + generated.contains(r#"hex"05""#), + "generator should embed the precomputed ULEB128 for index 5 (0x05):\n{generated}" + ); + // The deserializer must validate against the actual sparse index set, + // not `choice < N`. + assert!( + generated.contains("choice == 0 || choice == 5 || choice == 128 || choice == 300"), + "deserializer should validate against the sparse index set:\n{generated}" + ); + + let test_code_path = path.join("test_code.sol"); + { + let mut test_code_file = File::create(&test_code_path)?; + writeln!( + test_code_file, + r#"/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Library.sol"; + +contract ExampleCode {{ + + function test_round_trip(bytes calldata input, uint64 expected_choice) external {{ + Library.Sparse memory t = Library.bcs_deserialize_Sparse(input); + require(t.choice == expected_choice, "wrong choice"); + bytes memory input_rev = Library.bcs_serialize_Sparse(t); + require(input.length == input_rev.length, "length mismatch"); + for (uint256 i=0; i, u64)> = vec![ + // index 0 — single-byte ULEB128 + (vec![0x00], 0), + // index 5 — single-byte ULEB128, sparse + (vec![0x05], 5), + // index 128 — first two-byte ULEB128, sparse + (vec![0x80, 0x01], 128), + // index 300 — two-byte ULEB128 + u32 payload + ( + { + let mut v = vec![0xac, 0x02]; + v.extend_from_slice(&0xdead_beef_u32.to_le_bytes()); + v + }, + 300, + ), + ]; + + for (input_bytes, expected_choice) in cases { + let input = Bytes::copy_from_slice(&input_bytes); + let fct_args = test_round_tripCall { + input, + expected_choice, + }; + let fct_args = fct_args.abi_encode().into(); + test_contract(bytecode.clone(), fct_args); + } + + Ok(()) +} + +/// Regression test for the trailing-comma bug in the complex-Enum codegen. +/// +/// When an enum reaches the struct-backed `Enum` path with *no* payload-bearing +/// variants, the generated struct has only the `choice` field. The case +/// constructors and the deserializer return statement used to hardcode a comma +/// after `choice`, producing invalid Solidity like `Foo(uint64(0), )`. +/// +/// Two shapes hit this path: +/// - sparse all-Unit enums (sparse indices disqualify SimpleEnum); +/// - contiguous all-Unit enums with >256 variants (over the Solidity-enum cap). +/// +/// This test exercises the sparse case; the fix covers both. +#[test] +fn test_enum_sparse_all_unit() -> anyhow::Result<()> { + use serde_reflection::{ContainerFormat, Named, Registry, VariantFormat}; + use std::collections::BTreeMap; + + let mut variants: BTreeMap> = BTreeMap::new(); + for &(idx, name) in &[(0u32, "Zero"), (5, "Five"), (128, "OneTwentyEight")] { + variants.insert( + idx, + Named { + name: name.into(), + value: VariantFormat::Unit, + }, + ); + } + + let mut registry = Registry::new(); + registry.insert("AllUnitSparse".into(), ContainerFormat::Enum(variants)); + + let dir = tempdir().unwrap(); + let path = dir.path(); + + let test_library_path = path.join("Library.sol"); + { + let mut test_library_file = File::create(&test_library_path)?; + let config = CodeGeneratorConfig::new("Library".to_string()); + let generator = solidity::CodeGenerator::new(&config); + generator.output(&mut test_library_file, ®istry).unwrap(); + } + + // The bug manifests as `Foo(..., )` in the generated source; assert it + // never appears so a future regression fails at the unit-test layer + // before we even invoke solc. + let generated = std::fs::read_to_string(&test_library_path)?; + assert!( + !generated.contains(", )"), + "generator emitted a trailing comma in a struct constructor:\n{generated}" + ); + + let test_code_path = path.join("test_code.sol"); + { + let mut test_code_file = File::create(&test_code_path)?; + writeln!( + test_code_file, + r#"/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Library.sol"; + +contract ExampleCode {{ + + function test_round_trip(bytes calldata input, uint64 expected_choice) external {{ + Library.AllUnitSparse memory t = Library.bcs_deserialize_AllUnitSparse(input); + require(t.choice == expected_choice, "wrong choice"); + bytes memory input_rev = Library.bcs_serialize_AllUnitSparse(t); + require(input.length == input_rev.length, "length mismatch"); + for (uint256 i=0; i, u64)> = + vec![(vec![0x00], 0), (vec![0x05], 5), (vec![0x80, 0x01], 128)]; + + for (input_bytes, expected_choice) in cases { + let input = Bytes::copy_from_slice(&input_bytes); + let fct_args = test_round_tripCall { + input, + expected_choice, + }; + let fct_args = fct_args.abi_encode().into(); + test_contract(bytecode.clone(), fct_args); + } + + Ok(()) +} + +/// Regression test for the SimpleEnum (native Solidity `enum`) path with +/// variant indices >= 128, which require multi-byte ULEB128. +/// +/// The previous SimpleEnum codec encoded the choice as a single byte +/// (`abi.encodePacked(input)` / `uint8(input[pos])`), which silently produced +/// wrong BCS for indices >= 128. The fix routes SimpleEnum through the same +/// ULEB128 helper as the complex Enum path; this test pins that down. +#[test] +fn test_simple_enum_uleb128_variant_index() -> anyhow::Result<()> { + use serde_reflection::{ContainerFormat, Named, Registry, VariantFormat}; + use std::collections::BTreeMap; + + // 200 contiguous Unit variants → routes through SimpleEnum (is_trivial && + // is_contiguous && len <= 256). + let mut variants: BTreeMap> = BTreeMap::new(); + for i in 0..200u32 { + variants.insert( + i, + Named { + name: format!("V{i}"), + value: VariantFormat::Unit, + }, + ); + } + + let mut registry = Registry::new(); + registry.insert("BigSimple".into(), ContainerFormat::Enum(variants)); + + let dir = tempdir().unwrap(); + let path = dir.path(); + + let test_library_path = path.join("Library.sol"); + { + let mut test_library_file = File::create(&test_library_path)?; + let config = CodeGeneratorConfig::new("Library".to_string()); + let generator = solidity::CodeGenerator::new(&config); + generator.output(&mut test_library_file, ®istry).unwrap(); + } + + // Confirm the SimpleEnum (native `enum`) path was selected and that the + // codec goes through ULEB128 rather than a single byte. + let generated = std::fs::read_to_string(&test_library_path)?; + assert!( + generated.contains("enum BigSimple {"), + "expected SimpleEnum path (native enum) for 200 trivial variants:\n{generated}" + ); + assert!( + generated.contains("return bcs_serialize_uleb128(uint256(input));"), + "SimpleEnum serializer should go through bcs_serialize_uleb128:\n{generated}" + ); + + let test_code_path = path.join("test_code.sol"); + { + let mut test_code_file = File::create(&test_code_path)?; + writeln!( + test_code_file, + r#"/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Library.sol"; + +contract ExampleCode {{ + + function test_round_trip(bytes calldata input, uint8 expected_choice) external {{ + Library.BigSimple t = Library.bcs_deserialize_BigSimple(input); + require(uint8(t) == expected_choice, "wrong choice"); + bytes memory input_rev = Library.bcs_serialize_BigSimple(t); + require(input.length == input_rev.length, "length mismatch"); + for (uint256 i=0; i= 128) ULEB128 + // paths. 128 is the smallest multi-byte index; 199 is the largest variant. + let cases: Vec<(Vec, u8)> = vec![ + (vec![0x00], 0), + (vec![0x7f], 127), + (vec![0x80, 0x01], 128), + (vec![0xc7, 0x01], 199), + ]; + + for (input_bytes, expected_choice) in cases { + let input = Bytes::copy_from_slice(&input_bytes); + let fct_args = test_round_tripCall { + input, + expected_choice, + }; + let fct_args = fct_args.abi_encode().into(); + test_contract(bytecode.clone(), fct_args); + } + + Ok(()) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ComplexStruct { v1: [u8; 32],