From 338e7ff5a0df740fa7a6e41e7c6d5f4f11fa1478 Mon Sep 17 00:00:00 2001 From: deuszx Date: Thu, 14 May 2026 12:08:20 +0200 Subject: [PATCH] solidity: byte-swap + single mstore for uint64/uint128 (de)serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the byte-by-byte little-endian construction loop with a constant-time byte-swap chain followed by a single mstore/mload. The swap chain reverses byte order (BCS is little-endian, EVM mstore is big-endian); the assembly block then deposits the swapped value at the top of a 32-byte word so mstore writes the BCS bytes at result[0..N] in one operation. The deserialize path mirrors this with mload + shr + the same swap chain. The deserializers reintroduce the bounds check that the old byte-by-byte path got for free from Solidity's `input[pos + i]` indexing: require(pos + 8 <= input.length, "uint64 deserialize: out of bounds"); require(pos + 16 <= input.length, "uint128 deserialize: out of bounds"); Without these, mload would silently read up to 24 (resp. 16) bytes past the end of `input` for short payloads and return a garbage value. With them, the assembly read is also legal under the `memory-safe` contract, since `bytes memory` data slots are allocated rounded up to 32 bytes. Each unrolled swap chain carries a comment noting that EVM has no native byte-swap and describing what each term does, to make future edits less error-prone. Coverage: * `test_uint64_endian_boundaries` / `test_uint128_endian_boundaries` round-trip byte-distinct values (0, 1, 0xff, 0x100, 0x0102…, max) to catch endian bugs in the swap formula. * `test_uint_deserialize_truncated_input_reverts` calls `bcs_deserialize_offset_uint64` with 7 bytes and `bcs_deserialize_offset_uint128` with 15 bytes and asserts that each reverts rather than returning a garbage value past the input. --- serde-generate/src/solidity.rs | 110 +++++++++++++--- serde-generate/tests/solidity_runtime.rs | 161 +++++++++++++++++++++++ 2 files changed, 253 insertions(+), 18 deletions(-) diff --git a/serde-generate/src/solidity.rs b/serde-generate/src/solidity.rs index 18211caa3..acf9d0c80 100644 --- a/serde-generate/src/solidity.rs +++ b/serde-generate/src/solidity.rs @@ -492,11 +492,25 @@ function bcs_serialize_uint64(uint64 input) returns (bytes memory) {{ bytes memory result = new bytes(8); - uint64 value = input; - result[0] = bytes1(uint8(value)); - for (uint i=1; i<8; i++) {{ - value = value >> 8; - result[i] = bytes1(uint8(value)); + // EVM has no native byte-swap. The 8-term chain below relocates each + // input byte to its little-endian position in the low 8 bytes of + // `swapped`. Each term masks one source byte and shifts it to the + // target position; combined, they reverse the byte order. + // BCS is little-endian; mstore is big-endian, so the swap is needed. + uint256 swapped = + ((uint256(input) & 0xff) << 56) | + ((uint256(input) & 0xff00) << 40) | + ((uint256(input) & 0xff0000) << 24) | + ((uint256(input) & 0xff000000) << 8) | + ((uint256(input) & 0xff00000000) >> 8) | + ((uint256(input) & 0xff0000000000) >> 24) | + ((uint256(input) & 0xff000000000000) >> 40) | + ((uint256(input) & 0xff00000000000000) >> 56); + assembly ("memory-safe") {{ + // Shift the 8-byte swapped value to the top of the 32-byte word + // so mstore deposits it at result[0..8]. The trailing 24 zero bytes + // overwrite the round-up padding allocated by `new bytes(8)`. + mstore(add(result, 0x20), shl(192, swapped)) }} return result; }} @@ -506,11 +520,26 @@ function bcs_deserialize_offset_uint64(uint256 pos, bytes memory input) pure returns (uint256, uint64) {{ - uint64 value = uint8(input[pos + 7]); - for (uint256 i=0; i<7; i++) {{ - value = value << 8; - value += uint8(input[pos + 6 - i]); + // Bounds check: mload reads 32 bytes, but we only consume the top 8. + // The check is on the 8 bytes we actually decode; the require also + // makes the trailing read inside the assembly block legal, since + // `bytes memory` data slots are allocated rounded up to 32 bytes. + require(pos + 8 <= input.length, "uint64 deserialize: out of bounds"); + uint256 raw; + assembly ("memory-safe") {{ + // Load 32 bytes starting at input[pos], then drop the trailing 24 bytes. + // After shr(192), `raw` holds the 8 BCS bytes in big-endian layout. + raw := shr(192, mload(add(add(input, 0x20), pos))) }} + uint64 value = uint64( + ((raw & 0xff) << 56) | + ((raw & 0xff00) << 40) | + ((raw & 0xff0000) << 24) | + ((raw & 0xff000000) << 8) | + ((raw & 0xff00000000) >> 8) | + ((raw & 0xff0000000000) >> 24) | + ((raw & 0xff000000000000) >> 40) | + ((raw & 0xff00000000000000) >> 56)); return (pos + 8, value); }}"# )?; @@ -525,11 +554,33 @@ function bcs_serialize_uint128(uint128 input) returns (bytes memory) {{ bytes memory result = new bytes(16); - uint128 value = input; - result[0] = bytes1(uint8(value)); - for (uint i=1; i<16; i++) {{ - value = value >> 8; - result[i] = bytes1(uint8(value)); + // EVM has no native byte-swap. The 16-term chain below relocates each + // input byte to its little-endian position in the low 16 bytes of + // `swapped`. Each term masks one source byte and shifts it to the + // target position; combined, they reverse the byte order. + // BCS is little-endian; mstore is big-endian, so the swap is needed. + uint256 swapped = + ((uint256(input) & 0xff) << 120) | + ((uint256(input) & 0xff00) << 104) | + ((uint256(input) & 0xff0000) << 88) | + ((uint256(input) & 0xff000000) << 72) | + ((uint256(input) & 0xff00000000) << 56) | + ((uint256(input) & 0xff0000000000) << 40) | + ((uint256(input) & 0xff000000000000) << 24) | + ((uint256(input) & 0xff00000000000000) << 8) | + ((uint256(input) & 0xff0000000000000000) >> 8) | + ((uint256(input) & 0xff000000000000000000) >> 24) | + ((uint256(input) & 0xff00000000000000000000) >> 40) | + ((uint256(input) & 0xff0000000000000000000000) >> 56) | + ((uint256(input) & 0xff000000000000000000000000) >> 72) | + ((uint256(input) & 0xff00000000000000000000000000) >> 88) | + ((uint256(input) & 0xff0000000000000000000000000000) >> 104) | + ((uint256(input) & 0xff000000000000000000000000000000) >> 120); + assembly ("memory-safe") {{ + // Shift the 16-byte swapped value to the top of the 32-byte word + // so mstore deposits it at result[0..16]. The trailing 16 zero bytes + // overwrite the round-up padding allocated by `new bytes(16)`. + mstore(add(result, 0x20), shl(128, swapped)) }} return result; }} @@ -539,11 +590,34 @@ function bcs_deserialize_offset_uint128(uint256 pos, bytes memory input) pure returns (uint256, uint128) {{ - uint128 value = uint8(input[pos + 15]); - for (uint256 i=0; i<15; i++) {{ - value = value << 8; - value += uint8(input[pos + 14 - i]); + // Bounds check: mload reads 32 bytes, but we only consume the top 16. + // The check is on the 16 bytes we actually decode; the require also + // makes the trailing read inside the assembly block legal, since + // `bytes memory` data slots are allocated rounded up to 32 bytes. + require(pos + 16 <= input.length, "uint128 deserialize: out of bounds"); + uint256 raw; + assembly ("memory-safe") {{ + // Load 32 bytes starting at input[pos], then drop the trailing 16 bytes. + // After shr(128), `raw` holds the 16 BCS bytes in big-endian layout. + raw := shr(128, mload(add(add(input, 0x20), pos))) }} + uint128 value = uint128( + ((raw & 0xff) << 120) | + ((raw & 0xff00) << 104) | + ((raw & 0xff0000) << 88) | + ((raw & 0xff000000) << 72) | + ((raw & 0xff00000000) << 56) | + ((raw & 0xff0000000000) << 40) | + ((raw & 0xff000000000000) << 24) | + ((raw & 0xff00000000000000) << 8) | + ((raw & 0xff0000000000000000) >> 8) | + ((raw & 0xff000000000000000000) >> 24) | + ((raw & 0xff00000000000000000000) >> 40) | + ((raw & 0xff0000000000000000000000) >> 56) | + ((raw & 0xff000000000000000000000000) >> 72) | + ((raw & 0xff00000000000000000000000000) >> 88) | + ((raw & 0xff0000000000000000000000000000) >> 104) | + ((raw & 0xff000000000000000000000000000000) >> 120)); return (pos + 16, value); }}"# )?; diff --git a/serde-generate/tests/solidity_runtime.rs b/serde-generate/tests/solidity_runtime.rs index b2a0edad2..de073b7b6 100644 --- a/serde-generate/tests/solidity_runtime.rs +++ b/serde-generate/tests/solidity_runtime.rs @@ -196,6 +196,33 @@ fn test_vector_serialization_types() { test_vector_serialization(t).unwrap(); } +// Round-trip uint64/uint128 with byte-distinct values to catch endian bugs in +// the byte-swap + mstore-based (de)serializers. +#[test] +fn test_uint64_endian_boundaries() { + let cases_u64: &[u64] = &[0, 1, 0xff, 0x100, 0x0102_0304_0506_0708, u64::MAX]; + for v in cases_u64 { + let t = TestVec { vec: vec![*v] }; + test_vector_serialization(t).unwrap(); + } +} + +#[test] +fn test_uint128_endian_boundaries() { + let cases_u128: &[u128] = &[ + 0, + 1, + 0xff, + 0x100, + 0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10, + u128::MAX, + ]; + for v in cases_u128 { + let t = TestVec { vec: vec![*v] }; + test_vector_serialization(t).unwrap(); + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum SimpleEnumTestType { ChoiceA, @@ -602,3 +629,137 @@ contract ExampleCode {{ test_contract(bytecode.clone(), fct_args); Ok(()) } + +// Run a contract call that is expected to revert. Mirrors `test_contract` but +// asserts ExecutionResult::Revert / Halt rather than Success. +fn test_contract_expect_revert(bytecode: Bytes, encoded_args: Bytes) { + let mut database = CacheDB::new(EmptyDB::default()); + let deployer = Address::ZERO; + let contract_address = { + let deploy_nonce = nonce(&database, &deployer); + let result = Context::mainnet() + .with_db(&mut database) + .modify_cfg_chained(|cfg| { + cfg.limit_contract_code_size = Some(usize::MAX); + }) + .modify_tx_chained(|tx| { + tx.caller = deployer; + tx.nonce = deploy_nonce; + tx.kind = TxKind::Create; + tx.data = bytecode; + tx.gas_limit = u64::MAX; + tx.value = U256::ZERO; + }) + .build_mainnet() + .replay_commit() + .unwrap(); + + let ExecutionResult::Success { output, .. } = result else { + panic!("The TxKind::Create execution failed"); + }; + let Output::Create(_, Some(contract_address)) = output else { + panic!("Failure to create the contract"); + }; + contract_address + }; + + let call_nonce = nonce(&database, &deployer); + let result = Context::mainnet() + .with_db(&mut database) + .modify_cfg_chained(|cfg| { + cfg.limit_contract_code_size = Some(usize::MAX); + }) + .modify_tx_chained(|tx| { + tx.caller = deployer; + tx.nonce = call_nonce; + tx.kind = TxKind::Call(contract_address); + tx.data = encoded_args; + tx.gas_limit = u64::MAX; + tx.value = U256::ZERO; + }) + .build_mainnet() + .replay_commit() + .unwrap(); + + match result { + ExecutionResult::Revert { .. } | ExecutionResult::Halt { .. } => {} + ExecutionResult::Success { .. } => { + panic!("expected revert, but the call succeeded"); + } + } +} + +// The byte-swap deserializers `bcs_deserialize_offset_uint64` / +// `bcs_deserialize_offset_uint128` use an `mload` that reads 32 bytes from +// memory. The previous byte-by-byte loop got bounds checks for free via +// Solidity's `input[pos + i]` indexing; the new implementation must enforce +// them explicitly. This test confirms that truncated inputs revert rather +// than silently returning a garbage value. +#[test] +fn test_uint_deserialize_truncated_input_reverts() -> anyhow::Result<()> { + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] + struct WithU64U128 { + a: u64, + b: u128, + } + let registry = get_registry_from_type::(); + let dir = tempdir().unwrap(); + let path = dir.path(); + + { + let mut test_library_file = File::create(path.join("Library.sol"))?; + 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 mut test_code_file = File::create(path.join("test_code.sol"))?; + writeln!( + test_code_file, + r#"/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./Library.sol"; + +contract ExampleCode {{ + function test_truncated_uint64(bytes calldata input) external pure {{ + bytes memory mem_input = input; + Library.bcs_deserialize_offset_uint64(0, mem_input); + }} + + function test_truncated_uint128(bytes calldata input) external pure {{ + bytes memory mem_input = input; + Library.bcs_deserialize_offset_uint128(0, mem_input); + }} +}} +"# + )?; + } + + let bytecode = get_bytecode(path, "test_code.sol", "ExampleCode")?; + + sol! { + function test_truncated_uint64(bytes calldata input); + function test_truncated_uint128(bytes calldata input); + } + + // 7-byte input: one short of the 8 bytes uint64 needs. + { + let input = Bytes::copy_from_slice(&[0u8; 7]); + let fct_args = test_truncated_uint64Call { input }; + let fct_args = fct_args.abi_encode().into(); + test_contract_expect_revert(bytecode.clone(), fct_args); + } + + // 15-byte input: one short of the 16 bytes uint128 needs. + { + let input = Bytes::copy_from_slice(&[0u8; 15]); + let fct_args = test_truncated_uint128Call { input }; + let fct_args = fct_args.abi_encode().into(); + test_contract_expect_revert(bytecode.clone(), fct_args); + } + + Ok(()) +}