Skip to content

test(l2): pin chain_id encoding mismatch in l2_in_message_rolling_hashes public-input commitment#6540

Closed
avilagaston9 wants to merge 1 commit into
mainfrom
security/l2-rolling-hash-chain-id-encoding
Closed

test(l2): pin chain_id encoding mismatch in l2_in_message_rolling_hashes public-input commitment#6540
avilagaston9 wants to merge 1 commit into
mainfrom
security/l2-rolling-hash-chain-id-encoding

Conversation

@avilagaston9

Copy link
Copy Markdown
Contributor

Motivation

The L1 verifier in
OnChainProposer.sol::_getPublicInputsFromCommitment appends, for
every entry in currentBatch.l2InMessageRollingHashes,

abi.encodePacked(publicInputs, bytes32(rh.chainId), rh.rollingHash)

64 bytes per entry: 32 bytes for the chain id (uint256 in
the struct, left-padded by the bytes32 cast) plus 32 bytes for the
rolling hash.

The guest-side ProgramOutput::encode in
crates/guest-program/src/l2/output.rs, used as the SP1 / RISC0
public commitment via commit_slice(&output.encode()), instead
appends

for (chain_id, hash) in &self.l2_in_message_rolling_hashes {
    encoded.extend_from_slice(&chain_id.to_be_bytes());
    encoded.extend_from_slice(&hash.to_fixed_bytes());
}

where chain_id is typed as u64 (Vec<(u64, H256)>), so
u64::to_be_bytes() returns 8 bytes, not 32 — yielding 40 bytes
per entry
.

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 on-chain proof verification
fails. The committer happily produces commitments and the prover
happily produces proofs; verification just always reverts.

The other rolling-hash field (l1_in_messages_rolling_hash)
doesn't have this issue because it is a single bytes32. The
BalanceDiff::chain_id and top-level chain_id fields encode
correctly because both are U256 and round-trip via
to_big_endian() as 32 bytes. The bug is local to the
(u64, H256) typing of l2_in_message_rolling_hashes in
ProgramOutput.

Description

Adds a unit test
l2_in_message_rolling_hashes_chain_id_is_only_8_bytes_not_32
in crates/guest-program/src/l2/output.rs that builds a
ProgramOutput with everything zeroed except a single
l2_in_message_rolling_hashes entry, isolates the per-entry bytes
past the 256-byte fixed prefix, and asserts:

  • encoded.len() == PREFIX_LEN + 40 (the buggy 8+32 layout).
  • The chain_id bytes at [PREFIX_LEN..PREFIX_LEN+8] are exactly
    chain_id.to_be_bytes() — confirming it's the bare u64 with
    no padding.
  • The rolling hash starts immediately at PREFIX_LEN+8, not at
    PREFIX_LEN+32.
  • The full per-entry slice does NOT match what L1's
    abi.encodePacked(bytes32(chainId), rollingHash) would produce.

The fix is intentionally not in this PR. Two reasonable shapes:

  1. Change l2_in_message_rolling_hashes to Vec<(U256, H256)> and
    encode with chain_id.to_big_endian(), matching the pattern
    already used for BalanceDiff::chain_id and the top-level
    chain_id.
  2. Keep the type but pad on encode: write 24 zero bytes followed by
    chain_id.to_be_bytes() so each entry's wire format is 32+32.

Either makes the per-entry contribution 64 bytes, matching L1.
Once landed, the assert_ne! and length assertions in the test
flip direction, prompting an explicit review of the wire-format
change.

Reproduction

cargo test -p ethrex-guest-program \
  l2_in_message_rolling_hashes_chain_id_is_only_8_bytes_not_32

Passes on main (the bug is real and reachable).

Impact

L2-to-L2 cross-chain messaging is broken at the proof-verification
layer: any batch that includes one or more L2-in privileged
transactions cannot be verified on L1. The committer commits, the
prover proves, the verifier rejects — the L2 stalls. Bridges that
rely on L2↔L2 settlement can't finalize.

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync.

…Output.

The L1 verifier in `OnChainProposer.sol::_getPublicInputsFromCommitment`
appends, for every entry in `currentBatch.l2InMessageRollingHashes`,

    abi.encodePacked(publicInputs, bytes32(rh.chainId), rh.rollingHash)

— 32 bytes for the chain id (uint256 in the struct, left-padded by the
bytes32 cast) plus 32 bytes for the rolling hash, for **64 bytes per
entry**.

The guest-side `ProgramOutput::encode` (used as the SP1/RISC0 public
commitment via `commit_slice(&output.encode())`) instead appends

    encoded.extend_from_slice(&chain_id.to_be_bytes());
    encoded.extend_from_slice(&hash.to_fixed_bytes());

where `chain_id` is typed as `u64` (`Vec<(u64, H256)>`), so
`u64::to_be_bytes()` returns 8 bytes, not 32. That comes out to **40
bytes per entry**.

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 on chain.
The other rolling-hash field (`l1_in_messages_rolling_hash`) doesn't
have this issue because it is a single bytes32, and the
`BalanceDiff::chain_id` and top-level `chain_id` fields encode
correctly because both are `U256` and round-trip via
`to_big_endian()` as 32 bytes — the bug is local to the `(u64, H256)`
typing of `l2_in_message_rolling_hashes`.

The new test `l2_in_message_rolling_hashes_chain_id_is_only_8_bytes_not_32`
in `crates/guest-program/src/l2/output.rs` builds an output with one
rolling-hash entry, isolates it past the 256-byte fixed prefix, and
asserts the per-entry contribution is 40 bytes, that the chain_id is
the bare big-endian u64 with no padding, and that the resulting
layout differs from what L1's `abi.encodePacked(bytes32(chainId),
rollingHash)` produces. After the fix — either widening the field to
`Vec<(U256, H256)>` and using `to_big_endian()` like the other
fields, or padding to 32 bytes on encode — the assertions flip
direction, prompting an explicit review of the wire-format change.
@github-actions

Copy link
Copy Markdown

Lines of code report

Total lines added: 52
Total lines removed: 0
Total lines changed: 52

Detailed view
+----------------------------------------------+-------+------+
| File                                         | Lines | Diff |
+----------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l2/output.rs | 104   | +52  |
+----------------------------------------------+-------+------+

@avilagaston9

avilagaston9 commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Closed for inactivity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant