From c9ad309d1fc3a1235db6ab095b0009eb28064a45 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 11 Feb 2026 12:55:20 -0800 Subject: [PATCH 1/4] Fix BytesN parsing when using valid hex values. --- cmd/crates/soroban-spec-tools/src/lib.rs | 27 +++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index 79671b831..fb0b7b78a 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -285,9 +285,12 @@ impl Spec { _ => Err(Error::Serde(e)), }, |val| match t { - ScType::U128 | ScType::I128 | ScType::U256 | ScType::I256 => { - Ok(Value::String(s.to_owned())) - } + ScType::U128 + | ScType::I128 + | ScType::U256 + | ScType::I256 + | ScType::Bytes + | ScType::BytesN(_) => Ok(Value::String(s.to_owned())), ScType::Timepoint | ScType::Duration => { // timepoint and duration both expect a JSON object with the value // being the u64 number as a string, and key being the type name @@ -1610,6 +1613,24 @@ mod tests { assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); } + #[test] + fn test_bytesn_32_conversion_all_digit_hex() { + let as_str = "4142434400000000000000000000000000000000000000000000000000000000"; + let parsed = + from_string_primitive(as_str, &ScType::BytesN(ScSpecTypeBytesN { n: 32 })).unwrap(); + let expected = ScVal::Bytes(ScBytes( + vec![ + 0x41, 0x42, 0x43, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ] + .try_into() + .unwrap(), + )); + assert_eq!(parsed, expected); + assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); + } + #[test] fn test_bytesn_32_conversion() { let as_str = "9af73e7070f88107cf6a03d8410caecf25fd9da24521edc076c25d559e6b4c87"; From 178ae06e8779e7a1d600fdfdc732fda13d836a53 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 11 Feb 2026 14:00:50 -0800 Subject: [PATCH 2/4] Add more test cases for other types. --- cmd/crates/soroban-spec-tools/src/lib.rs | 105 +++++++++++++++++++++-- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index fb0b7b78a..922425b07 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -285,12 +285,12 @@ impl Spec { _ => Err(Error::Serde(e)), }, |val| match t { - ScType::U128 - | ScType::I128 - | ScType::U256 - | ScType::I256 - | ScType::Bytes - | ScType::BytesN(_) => Ok(Value::String(s.to_owned())), + ScType::U128 | ScType::I128 | ScType::U256 | ScType::I256 => { + Ok(Value::String(s.to_owned())) + } + ScType::Bytes | ScType::BytesN(_) if matches!(val, Value::Number(_)) => { + Ok(Value::String(s.to_owned())) + } ScType::Timepoint | ScType::Duration => { // timepoint and duration both expect a JSON object with the value // being the u64 number as a string, and key being the type name @@ -1631,6 +1631,99 @@ mod tests { assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); } + #[test] + fn test_bytes_conversion_json_quoted_hex() { + // JSON-quoted string input: serde_json parses as Value::String, should still work + let as_str = r#""beefface""#; + let parsed = from_string_primitive(as_str, &ScType::Bytes).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0xbe, 0xef, 0xfa, 0xce].try_into().unwrap())); + assert_eq!(parsed, expected); + } + + #[test] + fn test_bytes_conversion_json_quoted_all_digit_hex() { + // JSON-quoted all-digit hex: serde_json parses as Value::String (not Number), should work + let as_str = r#""4554""#; + let parsed = from_string_primitive(as_str, &ScType::Bytes).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0x45, 0x54].try_into().unwrap())); + assert_eq!(parsed, expected); + } + + #[test] + fn test_bytesn_conversion_json_quoted_all_digit_hex() { + // JSON-quoted all-digit hex for BytesN + let as_str = r#""4554""#; + let parsed = + from_string_primitive(as_str, &ScType::BytesN(ScSpecTypeBytesN { n: 2 })).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0x45, 0x54].try_into().unwrap())); + assert_eq!(parsed, expected); + } + + #[test] + fn test_bytes_conversion_all_digit_hex_trailing_zeros() { + // All-digit hex ending in zeros — valid JSON number (no leading zero issue) + let as_str = "1234567890"; + let parsed = from_string_primitive(as_str, &ScType::Bytes).unwrap(); + let expected = ScVal::Bytes(ScBytes( + vec![0x12, 0x34, 0x56, 0x78, 0x90].try_into().unwrap(), + )); + assert_eq!(parsed, expected); + assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); + } + + #[test] + fn test_bytesn_conversion_all_digit_hex_trailing_zeros() { + // All-digit hex ending in zeros for BytesN + let as_str = "1234567890"; + let parsed = + from_string_primitive(as_str, &ScType::BytesN(ScSpecTypeBytesN { n: 5 })).unwrap(); + let expected = ScVal::Bytes(ScBytes( + vec![0x12, 0x34, 0x56, 0x78, 0x90].try_into().unwrap(), + )); + assert_eq!(parsed, expected); + assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); + } + + #[test] + fn test_bytes_conversion_single_byte_all_digits() { + // "10" is a valid JSON number, but should be parsed as hex [0x10] + let as_str = "10"; + let parsed = from_string_primitive(as_str, &ScType::Bytes).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0x10].try_into().unwrap())); + assert_eq!(parsed, expected); + assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); + } + + #[test] + fn test_bytesn_conversion_single_byte_all_digits() { + // "10" is a valid JSON number, but should be parsed as hex [0x10] for BytesN<1> + let as_str = "10"; + let parsed = + from_string_primitive(as_str, &ScType::BytesN(ScSpecTypeBytesN { n: 1 })).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0x10].try_into().unwrap())); + assert_eq!(parsed, expected); + assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); + } + + #[test] + fn test_bytes_conversion_array_input() { + // Array of byte values should also work + let as_str = "[190,239,250,206]"; + let parsed = from_string_primitive(as_str, &ScType::Bytes).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0xbe, 0xef, 0xfa, 0xce].try_into().unwrap())); + assert_eq!(parsed, expected); + } + + #[test] + fn test_bytesn_conversion_array_input() { + // Array of byte values should also work for BytesN + let as_str = "[190,239,250,206]"; + let parsed = + from_string_primitive(as_str, &ScType::BytesN(ScSpecTypeBytesN { n: 4 })).unwrap(); + let expected = ScVal::Bytes(ScBytes(vec![0xbe, 0xef, 0xfa, 0xce].try_into().unwrap())); + assert_eq!(parsed, expected); + } + #[test] fn test_bytesn_32_conversion() { let as_str = "9af73e7070f88107cf6a03d8410caecf25fd9da24521edc076c25d559e6b4c87"; From 6fc36a3275827333c1cda4cb4cfa5306d1a8480e Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 12 Feb 2026 12:30:01 -0800 Subject: [PATCH 3/4] Address feedback. --- cmd/crates/soroban-spec-tools/src/lib.rs | 65 ++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index 922425b07..4439d520d 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -882,8 +882,8 @@ pub fn from_json_primitives(v: &Value, t: &ScType) -> Result { (ScType::Address | ScType::MuxedAddress, Value::String(s)) => sc_address_from_json(s)?, // Bytes parsing - (bytes @ ScType::BytesN(_), Value::Number(n)) => { - from_json_primitives(&Value::String(format!("{n}")), bytes)? + (ScType::BytesN(_), Value::Number(_)) => { + return Err(Error::InvalidValue(Some(t.clone()))); } (ScType::BytesN(bytes), Value::String(s)) => ScVal::Bytes(ScBytes({ if bytes.n == 32 { @@ -900,8 +900,8 @@ pub fn from_json_primitives(v: &Value, t: &ScType) -> Result { .try_into() .map_err(|_| Error::InvalidValue(Some(t.clone())))? })), - (ScType::Bytes, Value::Number(n)) => { - from_json_primitives(&Value::String(format!("{n}")), &ScType::Bytes)? + (ScType::Bytes, Value::Number(_)) => { + return Err(Error::InvalidValue(Some(t.clone()))); } (ScType::Bytes, Value::String(s)) => ScVal::Bytes( hex::decode(s) @@ -1724,6 +1724,63 @@ mod tests { assert_eq!(parsed, expected); } + #[test] + fn test_bytes_from_json_number() { + // Value::Number for Bytes should return an error (hex data must be quoted strings) + let val = Value::Number(serde_json::Number::from(4554u64)); + let result = from_json_primitives(&val, &ScType::Bytes); + assert!(result.is_err()); + } + + #[test] + fn test_bytesn_from_json_number() { + // Value::Number for BytesN should return an error (hex data must be quoted strings) + let val = Value::Number(serde_json::Number::from(4554u64)); + let result = from_json_primitives(&val, &ScType::BytesN(ScSpecTypeBytesN { n: 2 })); + assert!(result.is_err()); + } + + #[test] + fn test_bytes_from_json_number_single_byte() { + // Value::Number for Bytes should return an error even for small values + let val = Value::Number(serde_json::Number::from(10u64)); + let result = from_json_primitives(&val, &ScType::Bytes); + assert!(result.is_err()); + } + + #[test] + fn test_bytes_vec_with_all_digit_hex() { + // Quoted hex strings inside a Vec should work + let as_str = r#"["4554"]"#; + let parsed = from_string_primitive( + as_str, + &ScType::Vec(Box::new(ScSpecTypeVec { + element_type: Box::new(ScType::Bytes), + })), + ) + .unwrap(); + let expected = ScVal::Vec(Some(ScVec::from( + VecM::try_from(vec![ScVal::Bytes(ScBytes( + vec![0x45, 0x54].try_into().unwrap(), + ))]) + .unwrap(), + ))); + assert_eq!(parsed, expected); + } + + #[test] + fn test_bytes_vec_with_numeric_element() { + // Bare numbers inside a Vec should error (hex data must be quoted) + let as_str = "[4554]"; + let result = from_string_primitive( + as_str, + &ScType::Vec(Box::new(ScSpecTypeVec { + element_type: Box::new(ScType::Bytes), + })), + ); + assert!(result.is_err()); + } + #[test] fn test_bytesn_32_conversion() { let as_str = "9af73e7070f88107cf6a03d8410caecf25fd9da24521edc076c25d559e6b4c87"; From 155d250bd548a9b8a8cde28ea8101307b4b16fb0 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 12 Feb 2026 12:58:06 -0800 Subject: [PATCH 4/4] Fix clippy warning. --- cmd/crates/soroban-spec-tools/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index 4439d520d..e33f7a493 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -882,9 +882,6 @@ pub fn from_json_primitives(v: &Value, t: &ScType) -> Result { (ScType::Address | ScType::MuxedAddress, Value::String(s)) => sc_address_from_json(s)?, // Bytes parsing - (ScType::BytesN(_), Value::Number(_)) => { - return Err(Error::InvalidValue(Some(t.clone()))); - } (ScType::BytesN(bytes), Value::String(s)) => ScVal::Bytes(ScBytes({ if bytes.n == 32 { // Bytes might be a strkey, try parsing it as one. Contract devs should use the new @@ -900,7 +897,7 @@ pub fn from_json_primitives(v: &Value, t: &ScType) -> Result { .try_into() .map_err(|_| Error::InvalidValue(Some(t.clone())))? })), - (ScType::Bytes, Value::Number(_)) => { + (ScType::BytesN(_) | ScType::Bytes, Value::Number(_)) => { return Err(Error::InvalidValue(Some(t.clone()))); } (ScType::Bytes, Value::String(s)) => ScVal::Bytes(