Skip to content
Draft
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
121 changes: 121 additions & 0 deletions crates/guest-program/src/l2/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,124 @@ impl ProgramOutput {
encoded
}
}

#[cfg(test)]
mod tests {
use super::*;
use ethrex_common::types::balance_diff::BalanceDiff;

/// Reproducer for an L1↔guest public-input encoding mismatch.
///
/// The L1 verifier in `OnChainProposer.sol::_getPublicInputsFromCommitment`
/// reconstructs the public inputs and `sha256`-hashes them. For each entry in
/// `currentBatch.l2InMessageRollingHashes`, the contract appends:
///
/// ```solidity
/// abi.encodePacked(
/// publicInputs,
/// bytes32(rh.chainId), // 32 bytes — chainId is uint256 in the struct
/// rh.rollingHash // 32 bytes
/// );
/// ```
///
/// — i.e. **64 bytes per entry**.
///
/// The guest-side `ProgramOutput::encode` (used as the SP1/RISC0 public
/// commitment via `commit_slice(&output.encode())`) appends:
///
/// ```ignore
/// for (chain_id, hash) in &self.l2_in_message_rolling_hashes {
/// encoded.extend_from_slice(&chain_id.to_be_bytes()); // u64 → 8 bytes
/// encoded.extend_from_slice(&hash.to_fixed_bytes()); // 32 bytes
/// }
/// ```
///
/// — only **40 bytes per entry**, because `chain_id` is typed as `u64`
/// (`Vec<(u64, H256)>`) and `u64::to_be_bytes()` returns 8 bytes.
///
/// Consequence: as soon as a batch contains any L2-in privileged
/// transactions (i.e. any L2-to-L2 messaging is exercised), the prover
/// commits to a public input that is shorter than the one the L1
/// reconstructs, the two `sha256` hashes diverge, and the proof fails
/// verification — bricking L2-to-L2 batches. The `l1_in_messages_rolling_hash`
/// path doesn't have this issue (it's a single bytes32). The
/// `BalanceDiff::chain_id` and the top-level `chain_id` fields are fine
/// because both are `U256` and round-trip through `to_big_endian()` as 32
/// bytes — so the bug is local to the `(u64, H256)` typing of
/// `l2_in_message_rolling_hashes`.
///
/// This test pins the current (buggy) byte layout. Two reasonable shapes
/// for the fix:
/// 1. Change the field type to `Vec<(U256, H256)>` and use
/// `chain_id.to_big_endian()` like the other fields.
/// 2. Keep the type but pad on encode: write 24 zero bytes followed by
/// `chain_id.to_be_bytes()` so the wire format is 32 bytes.
/// Both make the per-entry contribution 64 bytes, matching L1.
#[test]
fn l2_in_message_rolling_hashes_chain_id_is_only_8_bytes_not_32() {
// Build an output where every other field is empty / zero so we can
// isolate the `l2_in_message_rolling_hashes` contribution.
let chain_id: u64 = 0x1234_5678_9abc_def0;
let rolling_hash = H256([0xCC; 32]);

let output = ProgramOutput {
initial_state_hash: H256::zero(),
final_state_hash: H256::zero(),
l1_out_messages_merkle_root: H256::zero(),
l1_in_messages_rolling_hash: H256::zero(),
l2_in_message_rolling_hashes: vec![(chain_id, rolling_hash)],
blob_versioned_hash: H256::zero(),
last_block_hash: H256::zero(),
chain_id: U256::zero(),
non_privileged_count: U256::zero(),
balance_diffs: Vec::<BalanceDiff>::new(),
};

let encoded = output.encode();

// Fixed prefix: 8 H256/U256 fields × 32 bytes = 256 bytes.
const PREFIX_LEN: usize = 32 * 8;

// Current (buggy) per-entry size: 8 (u64) + 32 (H256) = 40 bytes.
// L1's expected per-entry size: 32 (bytes32 chainId) + 32 (H256) = 64 bytes.
assert_eq!(
encoded.len(),
PREFIX_LEN + 40,
"Guest commits 40 bytes per (chainId, rollingHash) entry; L1 reads 64. \
If this assertion now fails because encoded.len() == PREFIX_LEN + 64, \
the chain_id encoding was widened to bytes32 — update the assertion."
);

// Verify the chain_id bytes sit at the expected offset and are exactly
// u64::to_be_bytes (8 bytes, no padding).
let chain_id_bytes = &encoded[PREFIX_LEN..PREFIX_LEN + 8];
assert_eq!(
chain_id_bytes,
&chain_id.to_be_bytes(),
"chain_id should be the bare 8-byte big-endian u64 — confirming the bug",
);

// After the chain_id (offset PREFIX_LEN+8), the rolling hash starts
// immediately. L1 expects it at PREFIX_LEN+32.
let hash_bytes = &encoded[PREFIX_LEN + 8..PREFIX_LEN + 8 + 32];
assert_eq!(
hash_bytes, &rolling_hash.0,
"rollingHash starts right after the 8-byte chain_id, with no left-pad",
);

// Build what L1's `abi.encodePacked(bytes32(chainId), rollingHash)` would
// produce so the diff is explicit in the test output.
let mut expected_l1_per_entry = [0u8; 64];
// bytes32(uint256(chainId)): left-pad u64 to 32 bytes.
expected_l1_per_entry[24..32].copy_from_slice(&chain_id.to_be_bytes());
expected_l1_per_entry[32..64].copy_from_slice(&rolling_hash.0);

let guest_per_entry = &encoded[PREFIX_LEN..];
assert_ne!(
guest_per_entry,
expected_l1_per_entry.as_slice(),
"Guest and L1 disagree on the per-entry layout — this is the bug. \
After the fix, this assertion will flip to assert_eq!.",
);
}
}
Loading