Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 92 additions & 18 deletions serde-generate/src/solidity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}}
Expand All @@ -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);
}}"#
)?;
Expand All @@ -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;
}}
Expand All @@ -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);
}}"#
)?;
Expand Down
161 changes: 161 additions & 0 deletions serde-generate/tests/solidity_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<WithU64U128>();
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, &registry).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(())
}
Loading