Skip to content

Commit 4bb7bcf

Browse files
committed
fix(cast): consistent serialization of Uint/Ints depending on actual type
The current implementation dynamically tries to determine if the runtime value can fit in 64 bits, but this leads to inconsistent serialization. For instance if you were decoding an `uint[]`, some of the values that fit in 64 bits will serialize as number while others serialize as string making it require special handling on the user that is consuming the json. This change makes it so it uses the type information to determine the serialization. So the user will always know that specific types will always serialize to a number or a string depending on the number of bits that type uses.
1 parent 2686b4b commit 4bb7bcf

8 files changed

Lines changed: 153 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cast/src/args.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> {
831831
let tokens: Vec<serde_json::Value> = tokens
832832
.iter()
833833
.cloned()
834-
.map(|t| serialize_value_as_json(t, None))
834+
.map(|t| serialize_value_as_json(t, None, true))
835835
.collect::<Result<Vec<_>>>()
836836
.unwrap();
837837
let _ = sh_println!("{}", serde_json::to_string_pretty(&tokens).unwrap());

crates/cast/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ impl<P: Provider<N> + Clone + Unpin, N: Network> Cast<P, N> {
196196
} else if shell::is_json() {
197197
let tokens = decoded
198198
.into_iter()
199-
.map(|value| serialize_value_as_json(value, None))
199+
.map(|value| serialize_value_as_json(value, None, true))
200200
.collect::<eyre::Result<Vec<_>>>()?;
201201
serde_json::to_string_pretty(&tokens).unwrap()
202202
} else {
@@ -2529,7 +2529,7 @@ mod tests {
25292529
let calldata = "0xdb5b0ed700000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006772bf190000000000000000000000000000000000000000000000000000000000020716000000000000000000000000af9d27ffe4d51ed54ac8eec78f2785d7e11e5ab100000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000000404366a6dc4b2f348a85e0066e46f0cc206fca6512e0ed7f17ca7afb88e9a4c27000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000093922dee6e380c28a50c008ab167b7800bb24c2026cd1b22f1c6fb884ceed7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060f85e59ecad6c1a6be343a945abedb7d5b5bfad7817c4d8cc668da7d391faf700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000093dfbf04395fbec1f1aed4ad0f9d3ba880ff58a60485df5d33f8f5e0fb73188600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aa334a426ea9e21d5f84eb2d4723ca56b92382b9260ab2b6769b7c23d437b6b512322a25cecc954127e60cf91ef056ac1da25f90b73be81c3ff1872fa48d10c7ef1ccb4087bbeedb54b1417a24abbb76f6cd57010a65bb03c7b6602b1eaf0e32c67c54168232d4edc0bfa1b815b2af2a2d0a5c109d675a4f2de684e51df9abb324ab1b19a81bac80f9ce3a45095f3df3a7cf69ef18fc08e94ac3cbc1c7effeacca68e3bfe5d81e26a659b5";
25302530
let sig = "sequenceBatchesValidium((bytes32,bytes32,uint64,bytes32)[],uint64,uint64,address,bytes)";
25312531
let decoded = Cast::calldata_decode(sig, calldata, true).unwrap();
2532-
let json_value = serialize_value_as_json(DynSolValue::Array(decoded), None).unwrap();
2532+
let json_value = serialize_value_as_json(DynSolValue::Array(decoded), None, true).unwrap();
25332533
let expected = serde_json::json!([
25342534
[
25352535
[

crates/cast/tests/cli/selectors.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ casttest!(event_decode_with_sig, |_prj, cmd| {
140140

141141
cmd.args(["--json"]).assert_success().stdout_eq(str![[r#"
142142
[
143-
78,
143+
"78",
144144
"0x0000000000000000000000000000000000D0004F"
145145
]
146146
@@ -168,7 +168,7 @@ casttest!(error_decode_with_sig, |_prj, cmd| {
168168

169169
cmd.args(["--json"]).assert_success().stdout_eq(str![[r#"
170170
[
171-
101,
171+
"101",
172172
"0x0000000000000000000000000000000000D0004F"
173173
]
174174

crates/cheatcodes/src/json.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ impl Cheatcode for serializeJsonType_0Call {
318318
let Self { typeDescription, value } = self;
319319
let ty = resolve_type(typeDescription, state.struct_defs())?;
320320
let value = ty.abi_decode(value)?;
321-
let value = foundry_common::fmt::serialize_value_as_json(value, state.struct_defs())?;
321+
let value =
322+
foundry_common::fmt::serialize_value_as_json(value, state.struct_defs(), false)?;
322323
Ok(value.to_string().abi_encode())
323324
}
324325
}
@@ -659,7 +660,7 @@ fn serialize_json<FEN: FoundryEvmNetwork>(
659660
value_key: &str,
660661
value: DynSolValue,
661662
) -> Result {
662-
let value = foundry_common::fmt::serialize_value_as_json(value, state.struct_defs())?;
663+
let value = foundry_common::fmt::serialize_value_as_json(value, state.struct_defs(), false)?;
663664
let map = state.serialized_jsons.entry(object_key.into()).or_default();
664665
map.insert(value_key.into(), value);
665666
let stringified = serde_json::to_string(map).unwrap();
@@ -889,7 +890,7 @@ mod tests {
889890
proptest::proptest! {
890891
#[test]
891892
fn test_json_roundtrip_guessed(v in guessable_types()) {
892-
let json = serialize_value_as_json(v.clone(), None).unwrap();
893+
let json = serialize_value_as_json(v.clone(), None, false).unwrap();
893894
let value = json_value_to_token(&json, None).unwrap();
894895

895896
// do additional abi_encode -> abi_decode to avoid zero signed integers getting decoded as unsigned and causing assert_eq to fail.
@@ -899,14 +900,14 @@ mod tests {
899900

900901
#[test]
901902
fn test_json_roundtrip(v in any::<DynSolValue>().prop_filter("filter out values without type", |v| v.as_type().is_some())) {
902-
let json = serialize_value_as_json(v.clone(), None).unwrap();
903+
let json = serialize_value_as_json(v.clone(), None, false).unwrap();
903904
let value = parse_json_as(&json, &v.as_type().unwrap()).unwrap();
904905
assert_eq!(value, v);
905906
}
906907

907908
#[test]
908909
fn test_json_roundtrip_with_struct_defs((struct_defs, v) in custom_struct_strategy()) {
909-
let json = serialize_value_as_json(v.clone(), Some(&struct_defs)).unwrap();
910+
let json = serialize_value_as_json(v.clone(), Some(&struct_defs), false).unwrap();
910911
let sol_type = v.as_type().unwrap();
911912
let parsed_value = parse_json_as(&json, &sol_type).unwrap();
912913
assert_eq!(parsed_value, v);
@@ -1065,7 +1066,8 @@ mod tests {
10651066
};
10661067

10671068
// Serialize the value to JSON and verify that the order is preserved.
1068-
let json_value = serialize_value_as_json(item_struct, Some(&struct_defs.into())).unwrap();
1069+
let json_value =
1070+
serialize_value_as_json(item_struct, Some(&struct_defs.into()), false).unwrap();
10691071
let json_string = serde_json::to_string(&json_value).unwrap();
10701072
assert_eq!(json_string, r#"{"name":"Test Item","id":123,"active":true}"#);
10711073
}
@@ -1097,9 +1099,12 @@ mod tests {
10971099
};
10981100

10991101
// Serialize it. The resulting JSON should respect the struct definition order.
1100-
let json_value =
1101-
serialize_value_as_json(original_wallet.clone(), Some(&struct_defs.clone().into()))
1102-
.unwrap();
1102+
let json_value = serialize_value_as_json(
1103+
original_wallet.clone(),
1104+
Some(&struct_defs.clone().into()),
1105+
false,
1106+
)
1107+
.unwrap();
11031108
let json_string = serde_json::to_string(&json_value).unwrap();
11041109
assert_eq!(
11051110
json_string,

crates/common/fmt/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ tempo-alloy.workspace = true
3838
[dev-dependencies]
3939
foundry-macros.workspace = true
4040
similar-asserts.workspace = true
41+
proptest.workspace = true
4142

4243
[features]
4344
default = ["optimism"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 885aa25152cd93b8ddf5e98d7bfdc995d70d059b823b5589e793df41be92d9ce # shrinks to l = 0, h = 18446744073709551616

crates/common/fmt/src/dynamic.rs

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,27 @@ pub fn format_token_raw(value: &DynSolValue) -> String {
150150
}
151151

152152
/// Serializes given [DynSolValue] into a [serde_json::Value].
153+
///
154+
/// If `strict` is `true`, numeric values are serialized according to their Solidity type width:
155+
/// integers with `bits <= 64` become JSON numbers, while wider integer types are serialized as
156+
/// strings, even when a particular runtime value would fit into `i64`/`u64`.
153157
pub fn serialize_value_as_json(
154158
value: DynSolValue,
155159
defs: Option<&StructDefinitions>,
160+
strict: bool,
156161
) -> Result<Value> {
157162
if let Some(defs) = defs {
158-
_serialize_value_as_json(value, defs)
163+
_serialize_value_as_json(value, defs, strict)
159164
} else {
160-
_serialize_value_as_json(value, &StructDefinitions::default())
165+
_serialize_value_as_json(value, &StructDefinitions::default(), strict)
161166
}
162167
}
163168

164-
fn _serialize_value_as_json(value: DynSolValue, defs: &StructDefinitions) -> Result<Value> {
169+
fn _serialize_value_as_json(
170+
value: DynSolValue,
171+
defs: &StructDefinitions,
172+
strict: bool,
173+
) -> Result<Value> {
165174
match value {
166175
DynSolValue::Bool(b) => Ok(Value::Bool(b)),
167176
DynSolValue::String(s) => {
@@ -175,34 +184,38 @@ fn _serialize_value_as_json(value: DynSolValue, defs: &StructDefinitions) -> Res
175184
}
176185
DynSolValue::Bytes(b) => Ok(Value::String(hex::encode_prefixed(b))),
177186
DynSolValue::FixedBytes(b, size) => Ok(Value::String(hex::encode_prefixed(&b[..size]))),
178-
DynSolValue::Int(i, _) => {
179-
if let Ok(n) = i64::try_from(i) {
180-
// Use `serde_json::Number` if the number can be accurately represented.
181-
Ok(Value::Number(n.into()))
182-
} else {
187+
DynSolValue::Int(i, bits) => {
188+
match (i64::try_from(i), strict) {
189+
// In strict mode, return as number only if the type dictates so
190+
(Ok(n), true) if bits <= 64 => Ok(Value::Number(n.into())),
191+
// In normal mode, return as number if the number can be accurately represented.
192+
(Ok(n), false) => Ok(Value::Number(n.into())),
183193
// Otherwise, fallback to its string representation to preserve precision and ensure
184194
// compatibility with alloy's `DynSolType` coercion.
185-
Ok(Value::String(i.to_string()))
195+
_ => Ok(Value::String(i.to_string())),
186196
}
187197
}
188-
DynSolValue::Uint(i, _) => {
189-
if let Ok(n) = u64::try_from(i) {
190-
// Use `serde_json::Number` if the number can be accurately represented.
191-
Ok(Value::Number(n.into()))
192-
} else {
198+
DynSolValue::Uint(i, bits) => {
199+
match (u64::try_from(i), strict) {
200+
// In strict mode, return as number only if the type dictates so
201+
(Ok(n), true) if bits <= 64 => Ok(Value::Number(n.into())),
202+
// In normal mode, return as number if the number can be accurately represented.
203+
(Ok(n), false) => Ok(Value::Number(n.into())),
193204
// Otherwise, fallback to its string representation to preserve precision and ensure
194205
// compatibility with alloy's `DynSolType` coercion.
195-
Ok(Value::String(i.to_string()))
206+
_ => Ok(Value::String(i.to_string())),
196207
}
197208
}
198209
DynSolValue::Address(a) => Ok(Value::String(a.to_string())),
199210
DynSolValue::Array(e) | DynSolValue::FixedArray(e) => Ok(Value::Array(
200-
e.into_iter().map(|v| _serialize_value_as_json(v, defs)).collect::<Result<_>>()?,
211+
e.into_iter()
212+
.map(|v| _serialize_value_as_json(v, defs, strict))
213+
.collect::<Result<_>>()?,
201214
)),
202215
DynSolValue::CustomStruct { name, prop_names, tuple } => {
203216
let values = tuple
204217
.into_iter()
205-
.map(|v| _serialize_value_as_json(v, defs))
218+
.map(|v| _serialize_value_as_json(v, defs, strict))
206219
.collect::<Result<Vec<_>>>()?;
207220
let mut map: HashMap<String, Value> = prop_names.into_iter().zip(values).collect();
208221

@@ -222,7 +235,10 @@ fn _serialize_value_as_json(value: DynSolValue, defs: &StructDefinitions) -> Res
222235
Ok(Value::Object(map.into_iter().collect::<Map<String, Value>>()))
223236
}
224237
DynSolValue::Tuple(values) => Ok(Value::Array(
225-
values.into_iter().map(|v| _serialize_value_as_json(v, defs)).collect::<Result<_>>()?,
238+
values
239+
.into_iter()
240+
.map(|v| _serialize_value_as_json(v, defs, strict))
241+
.collect::<Result<_>>()?,
226242
)),
227243
DynSolValue::Function(_) => eyre::bail!("cannot serialize function pointer"),
228244
}
@@ -318,4 +334,95 @@ mod tests {
318334
"0xFb6916095cA1Df60bb79ce92cE3EA74c37c5d359"
319335
);
320336
}
337+
338+
#[test]
339+
fn strict_uint256_array_is_homogeneous() {
340+
let small = U256::from(1u64);
341+
let big = U256::from(1u64) << 200;
342+
343+
let arr =
344+
DynSolValue::Array(vec![DynSolValue::Uint(small, 256), DynSolValue::Uint(big, 256)]);
345+
346+
let json = serialize_value_as_json(arr, None, true).unwrap();
347+
348+
assert_eq!(
349+
json,
350+
serde_json::json!([
351+
"1",
352+
"1606938044258990275541962092341162602522202993782792835301376"
353+
])
354+
);
355+
}
356+
357+
proptest::proptest! {
358+
#[test]
359+
fn test_serialize_uint_as_json(l in 0u64..u64::MAX, h in ((u64::MAX as u128) + 1)..u128::MAX) {
360+
let l_min_bits = (64 - l.leading_zeros()) as usize;
361+
let h_min_bits = (128 - h.leading_zeros()) as usize;
362+
363+
// values that fit in u64 should be serialized as a number in !strict mode
364+
assert_eq!(
365+
serialize_value_as_json(DynSolValue::Uint(l.try_into().unwrap(), l_min_bits), None, false).unwrap(),
366+
serde_json::json!(l)
367+
);
368+
// values that dont fit in u64 should be serialized as a string in !strict mode
369+
assert_eq!(
370+
serialize_value_as_json(DynSolValue::Uint(h.try_into().unwrap(), h_min_bits), None, false).unwrap(),
371+
serde_json::json!(h.to_string())
372+
);
373+
374+
// values should be serialized according to the type
375+
// since l_min_bits <= 64, expect the serialization to be a number
376+
assert_eq!(
377+
serialize_value_as_json(DynSolValue::Uint(l.try_into().unwrap(), l_min_bits), None, true).unwrap(),
378+
serde_json::json!(l)
379+
);
380+
// since `h_min_bits` is specified for the number `l`, expect the serialization to be a string
381+
// even though `l` fits in a u64
382+
assert_eq!(
383+
serialize_value_as_json(DynSolValue::Uint(l.try_into().unwrap(), h_min_bits), None, true).unwrap(),
384+
serde_json::json!(l.to_string())
385+
);
386+
// since `h_min_bits` is specified for the number `h`, expect the serialization to be a string
387+
assert_eq!(
388+
serialize_value_as_json(DynSolValue::Uint(h.try_into().unwrap(), h_min_bits), None, true).unwrap(),
389+
serde_json::json!(h.to_string())
390+
);
391+
}
392+
393+
#[test]
394+
fn test_serialize_int_as_json(l in 0i64..=i64::MAX, h in ((i64::MAX as i128) + 1)..=i128::MAX) {
395+
let l_min_bits = (64 - (l as u64).leading_zeros()) as usize + 1;
396+
let h_min_bits = (128 - (h as u128).leading_zeros()) as usize + 1;
397+
398+
// values that fit in i64 should be serialized as a number in !strict mode
399+
assert_eq!(
400+
serialize_value_as_json(DynSolValue::Int(l.try_into().unwrap(), l_min_bits), None, false).unwrap(),
401+
serde_json::json!(l)
402+
);
403+
// values that dont fit in i64 should be serialized as a string in !strict mode
404+
assert_eq!(
405+
serialize_value_as_json(DynSolValue::Int(h.try_into().unwrap(), h_min_bits), None, false).unwrap(),
406+
serde_json::json!(h.to_string())
407+
);
408+
409+
// values should be serialized according to the type
410+
// since l_min_bits <= 64, expect the serialization to be a number
411+
assert_eq!(
412+
serialize_value_as_json(DynSolValue::Int(l.try_into().unwrap(), l_min_bits), None, true).unwrap(),
413+
serde_json::json!(l)
414+
);
415+
// since `h_min_bits` is specified for the number `l`, expect the serialization to be a string
416+
// even though `l` fits in an i64
417+
assert_eq!(
418+
serialize_value_as_json(DynSolValue::Int(l.try_into().unwrap(), h_min_bits), None, true).unwrap(),
419+
serde_json::json!(l.to_string())
420+
);
421+
// since `h_min_bits` is specified for the number `h`, expect the serialization to be a string
422+
assert_eq!(
423+
serialize_value_as_json(DynSolValue::Int(h.try_into().unwrap(), h_min_bits), None, true).unwrap(),
424+
serde_json::json!(h.to_string())
425+
);
426+
}
427+
}
321428
}

0 commit comments

Comments
 (0)