From 62326c3956c2f6f4ab6705e928427fdd47f033ec Mon Sep 17 00:00:00 2001 From: deuszx Date: Thu, 14 May 2026 16:09:29 +0200 Subject: [PATCH] solidity: write struct fields directly into result memory on deserialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bcs_deserialize_offset_` previously held every decoded field as a stack local until the final `Struct(field0, field1, ...)` constructor. At ~2 stack slots per field (the value plus the second slot of the `(new_pos, value)` tuple return) Yul ran out of the 16-slot budget when these helpers were inlined into wider call graphs. The current `linera-bridge` workaround is a `build.rs` shim that rewrites every `assembly { ... }` block in the generated file to `assembly ("memory-safe") { ... }` so Yul's optimizer is allowed to spill across them. Switch the generated code to declare the return value with a named binding (`returns (uint256, memory result)`) and write each field through `result.` as it is decoded. The only persistent locals are now `pos`, `new_pos`, and the `result` pointer — stack footprint is O(1) regardless of field count, and the final `Struct(...)` constructor call (which previously memcpy'd N locals into a freshly allocated struct) disappears. Validation against linera-protocol#6294: * Before this change, with the in-tree codegen output and the `memory-safe` annotations stripped, `forge build` on `linera-bridge/src/solidity` fails with "Cannot swap Variable var_input_2223_mpos … too deep in the stack by 1 slots" inside the inlined `verifyCertificate` deserializer chain. * After regenerating `BridgeTypes.sol` from the same registry snapshot using this commit's codegen — still without any `memory-safe` annotations — `forge build` succeeds. The `mark_assembly_memory_safe` shim in `linera-bridge/build.rs` becomes obsolete. Measured on linera-bridge after regen (via_ir, optimizer_runs=1, solc 0.8.30, evm_version=cancun): Metric | Old + shim | New (no shim) | Delta ------------------------------------|-------------|---------------|-------- LightClient runtime bytecode | 23 296 B | 22 725 B | -571 B EIP-170 headroom for LightClient | 1 280 B | 1 851 B | +571 B LightClient deployment size | 24 785 B | 24 214 B | -571 B FungibleBridge runtime bytecode | 10 987 B | 10 958 B | -29 B DeployLightClient script gas | 10 838 349 | 10 600 945 | -237 K Synthetic gas/bytecode measurement on a 16x Inner struct (Inner = { String, u64, u128, Vec }, via_ir = true, optimizer_runs = 200, solc 0.8.33): * deserialize gas: 217 224 -> 210 076 (-7 148, -3.3 %) * harness bytecode: 3 088 B -> 2 759 B (-329 B, -11 %) * deployment gas: 715 222 -> 644 074 (-71 K, -10 %) --- serde-generate/src/solidity.rs | 24 ++--- serde-generate/tests/solidity_runtime.rs | 126 +++++++++++++++++++++++ 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/serde-generate/src/solidity.rs b/serde-generate/src/solidity.rs index 18211caa3..81a391b66 100644 --- a/serde-generate/src/solidity.rs +++ b/serde-generate/src/solidity.rs @@ -894,6 +894,13 @@ function bcs_serialize_{name}({name} memory input) }; writeln!(out, " {block};")?; } + // Deserialize fields directly into the `result` struct in memory. + // Holding each decoded field as a stack local until the final + // constructor call burns ~2 stack slots per field, which trips + // Yul's 16-slot limit for structs with >7 or so fields. Writing + // into `result.` mstores the value straight into memory + // and immediately frees the slot, keeping the stack flat at + // O(1) regardless of field count. writeln!( out, r#"}} @@ -901,33 +908,22 @@ function bcs_serialize_{name}({name} memory input) function bcs_deserialize_offset_{name}(uint256 pos, bytes memory input) internal pure - returns (uint256, {name} memory) + returns (uint256, {name} memory result) {{ uint256 new_pos;"# )?; for (index, named_format) in formats.iter().enumerate() { - let data_location = sol_registry.data_location(&named_format.value); - let qualified_code_name = sol_registry.qualified_code_name(&named_format.value); let key_name = named_format.value.key_name(); let safe_name = safe_variable(&named_format.name); let start_pos = if index == 0 { "pos" } else { "new_pos" }; let deser_fn = sol_registry.qualified_fn_name("bcs_deserialize_offset", &key_name); - writeln!(out, " {qualified_code_name}{data_location} {safe_name};")?; writeln!( out, - " (new_pos, {safe_name}) = {deser_fn}({start_pos}, input);" + " (new_pos, result.{safe_name}) = {deser_fn}({start_pos}, input);" )?; } - writeln!( - out, - " return (new_pos, {name}({}));", - formats - .iter() - .map(|named_format| safe_variable(&named_format.name)) - .collect::>() - .join(", ") - )?; + writeln!(out, " return (new_pos, result);")?; writeln!(out, "}}")?; output_generic_bcs_deserialize(out, name, name, true)?; } diff --git a/serde-generate/tests/solidity_runtime.rs b/serde-generate/tests/solidity_runtime.rs index b2a0edad2..e3418006a 100644 --- a/serde-generate/tests/solidity_runtime.rs +++ b/serde-generate/tests/solidity_runtime.rs @@ -602,3 +602,129 @@ contract ExampleCode {{ test_contract(bytecode.clone(), fct_args); Ok(()) } + +// Wide struct of varied field types. With the old codegen pattern (one stack +// local per decoded field, plus the tuple-return slot), Yul ran out of the +// 16-slot stack budget when this deserializer is inlined into an external +// entry point. With the direct-into-result codegen the stack stays flat and +// the deserializer round-trips correctly regardless of width. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct WideMixedStruct { + pub a: bool, + pub b: u8, + pub c: u16, + pub d: u32, + pub e: u64, + pub f: u128, + pub g: i8, + pub h: i16, + pub i: i32, + pub j: i64, + pub k: i128, + pub l: String, + #[serde(with = "serde_bytes")] + pub m: Vec, + pub n: Vec, + pub o: Option, + pub p: [u8; 32], + pub q: bool, + pub r: u64, + pub s: u128, + pub t: String, +} + +#[test] +fn test_wide_struct_deserialize_round_trip() -> anyhow::Result<()> { + let registry = get_registry_from_type::(); + 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 name = "Library".to_string(); + let config = CodeGeneratorConfig::new(name); + 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_deserialization(bytes calldata input) external pure {{ + Library.WideMixedStruct memory t = Library.bcs_deserialize_WideMixedStruct(input); + // Spot-check fields across the range to ensure each result. + // assignment landed in the right struct slot. + require(t.a == true, "field a"); + require(t.f == 0x0102030405060708090a0b0c0d0e0f10, "field f"); + require(t.k == -1, "field k"); + require(keccak256(bytes(t.l)) == keccak256(bytes("hello")), "field l"); + require(t.m.length == 4, "field m length"); + require(t.m[0] == 0xaa && t.m[3] == 0xdd, "field m bytes"); + require(t.n.length == 3 && t.n[2] == 99, "field n"); + require(t.o.has_value && t.o.value == 0xfeedbeefdeadc0de, "field o"); + require(t.p[0] == 0x01 && t.p[31] == 0x20, "field p"); + require(bytes(t.t).length == 5, "field t"); + + // Round-trip: serialize back and assert byte-equality with the input. + bytes memory input_rev = Library.bcs_serialize_WideMixedStruct(t); + require(input_rev.length == input.length, "round-trip length"); + for (uint256 idx = 0; idx < input.length; idx++) {{ + require(input[idx] == input_rev[idx], "round-trip byte"); + }} + }} +}} +"# + )?; + } + + let bytecode = get_bytecode(path, "test_code.sol", "ExampleCode")?; + + let t = WideMixedStruct { + a: true, + b: 0x42, + c: 0x4243, + d: 0xdead_beef, + e: 0x0102_0304_0506_0708, + f: 0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10, + g: -1, + h: -2, + i: -3, + j: -4, + k: -1, + l: "hello".to_string(), + m: vec![0xaa, 0xbb, 0xcc, 0xdd], + n: vec![1, 2, 99], + o: Some(0xfeed_beef_dead_c0de), + p: { + let mut a = [0u8; 32]; + for (i, x) in a.iter_mut().enumerate() { + *x = (i + 1) as u8; + } + a + }, + q: false, + r: 0, + s: 0, + t: "world".to_string(), + }; + let expected_input = bcs::to_bytes(&t).unwrap(); + + sol! { + function test_deserialization(bytes calldata input); + } + let input = Bytes::copy_from_slice(&expected_input); + let fct_args = test_deserializationCall { input }; + let fct_args = fct_args.abi_encode().into(); + + test_contract(bytecode, fct_args); + Ok(()) +}